detached 98d2616

Merge pull request 'feat(tokens): branch-limited tokens for agents' (#13) from feat/token-branch-prefixes into main

commit
98d26163af9651441791c41929d4eb96e3a28976
author
bnema · · 2026-10-12 20:11 UTC
merge of
9b24d95 + f9ee4b9 · diff against the first parent
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
44 files · +1515 -114
M README.md +14 -1
@@ -62,8 +62,10 @@ ermite config show [--json] # path and stored values
6262 ```sh
6363 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
6464 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
65-ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once
65+ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once
6666 ermite token list | revoke <id>
67+# a token for an AI agent that cannot touch main:
68+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
6769 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
6870 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
6971 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
@@ -91,6 +93,17 @@ git push http://u:<token>@git.example.com/<repo>.git main
9193
9294 Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
9395
96+## Branch-limited tokens
97+
98+For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes.
99+
100+- It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
101+- It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
102+- It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
103+- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
104+- `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
105+- This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade.
106+
94107 ## Merge requests
95108
96109 A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch).
@@ -62,8 +62,10 @@ ermite config show [--json] # path and stored values
62 ```sh62 ```sh
63 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...63 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
64 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]64 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
65-ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once65+ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once
66 ermite token list | revoke <id>66 ermite token list | revoke <id>
67+# a token for an AI agent that cannot touch main:
68+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
67 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]69 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
68 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>70 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
69 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>71 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
@@ -91,6 +93,17 @@ git push http://u:<token>@git.example.com/<repo>.git main
91 93
92 Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).94 Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
93 95
96+## Branch-limited tokens
97+
98+For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes.
99+
100+- It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
101+- It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
102+- It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
103+- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
104+- `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
105+- This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade.
106+
94 ## Merge requests107 ## Merge requests
95 108
96 A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch).109 A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch).
M gen/ermite/storage/repo.pb.go +16 -5
@@ -226,9 +226,12 @@ type Token struct {
226226 ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
227227 CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
228228 // Unset until the token is first used.
229- LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
230- unknownFields protoimpl.UnknownFields
231- sizeCache protoimpl.SizeCache
229+ LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
230+ // Branch prefixes, each ending with "/", that the token may write; empty
231+ // means every branch. Added in storage schema 2.
232+ BranchPrefixes []string `protobuf:"bytes,9,rep,name=branch_prefixes,json=branchPrefixes,proto3" json:"branch_prefixes,omitempty"`
233+ unknownFields protoimpl.UnknownFields
234+ sizeCache protoimpl.SizeCache
232235 }
233236
234237 func (x *Token) Reset() {
@@ -317,6 +320,13 @@ func (x *Token) GetLastUsedAt() *timestamppb.Timestamp {
317320 return nil
318321 }
319322
323+func (x *Token) GetBranchPrefixes() []string {
324+ if x != nil {
325+ return x.BranchPrefixes
326+ }
327+ return nil
328+}
329+
320330 var File_ermite_storage_repo_proto protoreflect.FileDescriptor
321331
322332 const file_ermite_storage_repo_proto_rawDesc = "" +
@@ -333,7 +343,7 @@ const file_ermite_storage_repo_proto_rawDesc = "" +
333343 "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x128\n" +
334344 "\bsettings\x18\x06 \x01(\v2\x1c.ermite.storage.RepoSettingsR\bsettings\")\n" +
335345 "\rSchemaVersion\x12\x18\n" +
336- "\aversion\x18\x01 \x01(\rR\aversion\"\xa3\x02\n" +
346+ "\aversion\x18\x01 \x01(\rR\aversion\"\xcc\x02\n" +
337347 "\x05Token\x12\x0e\n" +
338348 "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
339349 "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" +
@@ -345,7 +355,8 @@ const file_ermite_storage_repo_proto_rawDesc = "" +
345355 "\n" +
346356 "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
347357 "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
348- "lastUsedAt*W\n" +
358+ "lastUsedAt\x12'\n" +
359+ "\x0fbranch_prefixes\x18\t \x03(\tR\x0ebranchPrefixes*W\n" +
349360 "\n" +
350361 "Visibility\x12\x1a\n" +
351362 "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" +
@@ -226,9 +226,12 @@ type Token struct {
226 ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`226 ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"`
227 CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`227 CreatedAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"`
228 // Unset until the token is first used.228 // Unset until the token is first used.
229- LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`229+ LastUsedAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=last_used_at,json=lastUsedAt,proto3" json:"last_used_at,omitempty"`
230- unknownFields protoimpl.UnknownFields230+ // Branch prefixes, each ending with "/", that the token may write; empty
231- sizeCache protoimpl.SizeCache231+ // means every branch. Added in storage schema 2.
232+ BranchPrefixes []string `protobuf:"bytes,9,rep,name=branch_prefixes,json=branchPrefixes,proto3" json:"branch_prefixes,omitempty"`
233+ unknownFields protoimpl.UnknownFields
234+ sizeCache protoimpl.SizeCache
232 }235 }
233 236
234 func (x *Token) Reset() {237 func (x *Token) Reset() {
@@ -317,6 +320,13 @@ func (x *Token) GetLastUsedAt() *timestamppb.Timestamp {
317 return nil320 return nil
318 }321 }
319 322
323+func (x *Token) GetBranchPrefixes() []string {
324+ if x != nil {
325+ return x.BranchPrefixes
326+ }
327+ return nil
328+}
329+
320 var File_ermite_storage_repo_proto protoreflect.FileDescriptor330 var File_ermite_storage_repo_proto protoreflect.FileDescriptor
321 331
322 const file_ermite_storage_repo_proto_rawDesc = "" +332 const file_ermite_storage_repo_proto_rawDesc = "" +
@@ -333,7 +343,7 @@ const file_ermite_storage_repo_proto_rawDesc = "" +
333 "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x128\n" +343 "\tpushed_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\bpushedAt\x128\n" +
334 "\bsettings\x18\x06 \x01(\v2\x1c.ermite.storage.RepoSettingsR\bsettings\")\n" +344 "\bsettings\x18\x06 \x01(\v2\x1c.ermite.storage.RepoSettingsR\bsettings\")\n" +
335 "\rSchemaVersion\x12\x18\n" +345 "\rSchemaVersion\x12\x18\n" +
336- "\aversion\x18\x01 \x01(\rR\aversion\"\xa3\x02\n" +346+ "\aversion\x18\x01 \x01(\rR\aversion\"\xcc\x02\n" +
337 "\x05Token\x12\x0e\n" +347 "\x05Token\x12\x0e\n" +
338 "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +348 "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" +
339 "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" +349 "\x05label\x18\x02 \x01(\tR\x05label\x12\x12\n" +
@@ -345,7 +355,8 @@ const file_ermite_storage_repo_proto_rawDesc = "" +
345 "\n" +355 "\n" +
346 "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +356 "created_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12<\n" +
347 "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +357 "\flast_used_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\n" +
348- "lastUsedAt*W\n" +358+ "lastUsedAt\x12'\n" +
359+ "\x0fbranch_prefixes\x18\t \x03(\tR\x0ebranchPrefixes*W\n" +
349 "\n" +360 "\n" +
350 "Visibility\x12\x1a\n" +361 "Visibility\x12\x1a\n" +
351 "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" +362 "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\x16\n" +
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
16- bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7
16+ bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a415 bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
16- bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b716+ bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c318 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client_test.go +2 -2
@@ -139,11 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146- client.FeatureCIRuns, client.FeatureCIRunners,
146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
147147 }))
148148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
149149 }
@@ -139,11 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139 func TestMissingFeatures(t *testing.T) {139 func TestMissingFeatures(t *testing.T) {
140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141 assert.Equal(t,141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners},142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners},
143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146- client.FeatureCIRuns, client.FeatureCIRunners,146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
147 }))147 }))
148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
149 }149 }
M internal/adapters/client/features.go +8 -1
@@ -22,6 +22,13 @@ const (
2222 FeatureSigningKey = "signing_key"
2323 // FeatureSession is SessionService (`ermite web login`, `web logout`).
2424 FeatureSession = "session"
25+ // FeatureTokenBranchPrefixes is Token.branch_prefixes and
26+ // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`).
27+ // A server without it ignores the field and would create an unlimited
28+ // token: `token create --branch-prefix` reads the features first and
29+ // refuses when it is missing (the main guard), and also checks the token it
30+ // gets back (a backstop).
31+ FeatureTokenBranchPrefixes = "token.branch_prefixes"
2532 // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner
2633 // calls (`ermite runner`).
2734 FeatureCIRuns = "ci.runs"
@@ -30,7 +37,7 @@ const (
3037
3138 // UsedFeatures lists the features the CLI uses, in the order they are reported.
3239 func UsedFeatures() []string {
33- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners}
40+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners}
3441 }
3542
3643 // MissingFeatures returns the features the CLI uses that have is lacking.
@@ -22,6 +22,13 @@ const (
22 FeatureSigningKey = "signing_key"22 FeatureSigningKey = "signing_key"
23 // FeatureSession is SessionService (`ermite web login`, `web logout`).23 // FeatureSession is SessionService (`ermite web login`, `web logout`).
24 FeatureSession = "session"24 FeatureSession = "session"
25+ // FeatureTokenBranchPrefixes is Token.branch_prefixes and
26+ // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`).
27+ // A server without it ignores the field and would create an unlimited
28+ // token: `token create --branch-prefix` reads the features first and
29+ // refuses when it is missing (the main guard), and also checks the token it
30+ // gets back (a backstop).
31+ FeatureTokenBranchPrefixes = "token.branch_prefixes"
25 // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner32 // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner
26 // calls (`ermite runner`).33 // calls (`ermite runner`).
27 FeatureCIRuns = "ci.runs"34 FeatureCIRuns = "ci.runs"
@@ -30,7 +37,7 @@ const (
30 37
31 // UsedFeatures lists the features the CLI uses, in the order they are reported.38 // UsedFeatures lists the features the CLI uses, in the order they are reported.
32 func UsedFeatures() []string {39 func UsedFeatures() []string {
33- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners}40+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners}
34 }41 }
35 42
36 // MissingFeatures returns the features the CLI uses that have is lacking.43 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/kvstore/store.go +23 -4
@@ -17,8 +17,11 @@ import (
1717 "bnema.dev/ermite/internal/ports"
1818 )
1919
20-// SchemaVersion is the storage schema this build reads and writes.
21-const SchemaVersion = 1
20+// SchemaVersion is the storage schema this build reads and writes. Version 2
21+// adds branch prefixes to tokens: a build that does not know them would treat
22+// a branch-limited token as unrestricted, so it must refuse the data
23+// (ErrSchemaTooNew) instead.
24+const SchemaVersion = 2
2225
2326 const schemaKey = "meta/schema"
2427
@@ -33,8 +36,8 @@ type Store struct {
3336 var _ ports.RepoMetaStore = (*Store)(nil)
3437
3538 // Open opens (or creates) the database in dir and checks the storage schema:
36-// a missing version is written as SchemaVersion, a higher one stops with
37-// ErrSchemaTooNew. dir must exist.
39+// a missing version is written as SchemaVersion, a lower one is upgraded to it,
40+// a higher one stops with ErrSchemaTooNew. dir must exist.
3841 func Open(ctx context.Context, dir string) (*Store, error) {
3942 db, err := kv.Open(ctx, dir, kv.Options{})
4043 if err != nil {
@@ -107,6 +110,22 @@ func (s *Store) checkSchema(ctx context.Context) error {
107110 case sv.GetVersion() == 0:
108111 return fmt.Errorf("decode storage schema: invalid version 0")
109112 }
113+ if sv.GetVersion() < SchemaVersion {
114+ // Older data stays readable as it is (the changes are additive);
115+ // the new version is recorded so that older builds refuse it.
116+ var b kv.Batch
117+ b.RequireVersion(key, item.Version)
118+ b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
119+ _, err := s.db.Apply(ctx, &b)
120+ if errors.Is(err, kv.ErrConflict) {
121+ continue // changed meanwhile: read it again
122+ }
123+ if err != nil {
124+ return fmt.Errorf("upgrade storage schema: %w", err)
125+ }
126+ log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded")
127+ return nil
128+ }
110129 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")
111130 return nil
112131 }
@@ -17,8 +17,11 @@ import (
17 "bnema.dev/ermite/internal/ports"17 "bnema.dev/ermite/internal/ports"
18 )18 )
19 19
20-// SchemaVersion is the storage schema this build reads and writes.20+// SchemaVersion is the storage schema this build reads and writes. Version 2
21-const SchemaVersion = 121+// adds branch prefixes to tokens: a build that does not know them would treat
22+// a branch-limited token as unrestricted, so it must refuse the data
23+// (ErrSchemaTooNew) instead.
24+const SchemaVersion = 2
22 25
23 const schemaKey = "meta/schema"26 const schemaKey = "meta/schema"
24 27
@@ -33,8 +36,8 @@ type Store struct {
33 var _ ports.RepoMetaStore = (*Store)(nil)36 var _ ports.RepoMetaStore = (*Store)(nil)
34 37
35 // Open opens (or creates) the database in dir and checks the storage schema:38 // Open opens (or creates) the database in dir and checks the storage schema:
36-// a missing version is written as SchemaVersion, a higher one stops with39+// a missing version is written as SchemaVersion, a lower one is upgraded to it,
37-// ErrSchemaTooNew. dir must exist.40+// a higher one stops with ErrSchemaTooNew. dir must exist.
38 func Open(ctx context.Context, dir string) (*Store, error) {41 func Open(ctx context.Context, dir string) (*Store, error) {
39 db, err := kv.Open(ctx, dir, kv.Options{})42 db, err := kv.Open(ctx, dir, kv.Options{})
40 if err != nil {43 if err != nil {
@@ -107,6 +110,22 @@ func (s *Store) checkSchema(ctx context.Context) error {
107 case sv.GetVersion() == 0:110 case sv.GetVersion() == 0:
108 return fmt.Errorf("decode storage schema: invalid version 0")111 return fmt.Errorf("decode storage schema: invalid version 0")
109 }112 }
113+ if sv.GetVersion() < SchemaVersion {
114+ // Older data stays readable as it is (the changes are additive);
115+ // the new version is recorded so that older builds refuse it.
116+ var b kv.Batch
117+ b.RequireVersion(key, item.Version)
118+ b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
119+ _, err := s.db.Apply(ctx, &b)
120+ if errors.Is(err, kv.ErrConflict) {
121+ continue // changed meanwhile: read it again
122+ }
123+ if err != nil {
124+ return fmt.Errorf("upgrade storage schema: %w", err)
125+ }
126+ log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded")
127+ return nil
128+ }
110 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")129 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")
111 return nil130 return nil
112 }131 }
M internal/adapters/kvstore/store_test.go +18 -5
@@ -191,12 +191,12 @@ func readSchema(t *testing.T, dir string) uint32 {
191191 func TestSchema(t *testing.T) {
192192 ctx := context.Background()
193193
194- t.Run("missing is written as 1", func(t *testing.T) {
194+ t.Run("missing is written as the current version", func(t *testing.T) {
195195 dir := t.TempDir()
196196 s, err := kvstore.Open(ctx, dir)
197197 require.NoError(t, err)
198198 require.NoError(t, s.Close(ctx))
199- assert.Equal(t, uint32(1), readSchema(t, dir))
199+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
200200 })
201201
202202 t.Run("current is accepted and kept", func(t *testing.T) {
@@ -207,15 +207,28 @@ func TestSchema(t *testing.T) {
207207 s, err = kvstore.Open(ctx, dir)
208208 require.NoError(t, err)
209209 require.NoError(t, s.Close(ctx))
210- assert.Equal(t, uint32(1), readSchema(t, dir))
210+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
211+ })
212+
213+ t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) {
214+ dir := t.TempDir()
215+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1}))
216+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
217+ s, err := kvstore.Open(ctx, dir)
218+ require.NoError(t, err)
219+ got, err := s.Get(ctx, name(t, "old"))
220+ require.NoError(t, err)
221+ assert.Equal(t, "old", got.Name.String())
222+ require.NoError(t, s.Close(ctx))
223+ assert.Equal(t, uint32(2), readSchema(t, dir))
211224 })
212225
213226 t.Run("newer stops startup and releases the lock", func(t *testing.T) {
214227 dir := t.TempDir()
215- putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
228+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
216229 _, err := kvstore.Open(ctx, dir)
217230 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
218- assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept")
231+ assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept")
219232 })
220233
221234 t.Run("garbage and zero are errors", func(t *testing.T) {
@@ -191,12 +191,12 @@ func readSchema(t *testing.T, dir string) uint32 {
191 func TestSchema(t *testing.T) {191 func TestSchema(t *testing.T) {
192 ctx := context.Background()192 ctx := context.Background()
193 193
194- t.Run("missing is written as 1", func(t *testing.T) {194+ t.Run("missing is written as the current version", func(t *testing.T) {
195 dir := t.TempDir()195 dir := t.TempDir()
196 s, err := kvstore.Open(ctx, dir)196 s, err := kvstore.Open(ctx, dir)
197 require.NoError(t, err)197 require.NoError(t, err)
198 require.NoError(t, s.Close(ctx))198 require.NoError(t, s.Close(ctx))
199- assert.Equal(t, uint32(1), readSchema(t, dir))199+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
200 })200 })
201 201
202 t.Run("current is accepted and kept", func(t *testing.T) {202 t.Run("current is accepted and kept", func(t *testing.T) {
@@ -207,15 +207,28 @@ func TestSchema(t *testing.T) {
207 s, err = kvstore.Open(ctx, dir)207 s, err = kvstore.Open(ctx, dir)
208 require.NoError(t, err)208 require.NoError(t, err)
209 require.NoError(t, s.Close(ctx))209 require.NoError(t, s.Close(ctx))
210- assert.Equal(t, uint32(1), readSchema(t, dir))210+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
211+ })
212+
213+ t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) {
214+ dir := t.TempDir()
215+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1}))
216+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
217+ s, err := kvstore.Open(ctx, dir)
218+ require.NoError(t, err)
219+ got, err := s.Get(ctx, name(t, "old"))
220+ require.NoError(t, err)
221+ assert.Equal(t, "old", got.Name.String())
222+ require.NoError(t, s.Close(ctx))
223+ assert.Equal(t, uint32(2), readSchema(t, dir))
211 })224 })
212 225
213 t.Run("newer stops startup and releases the lock", func(t *testing.T) {226 t.Run("newer stops startup and releases the lock", func(t *testing.T) {
214 dir := t.TempDir()227 dir := t.TempDir()
215- putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))228+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
216 _, err := kvstore.Open(ctx, dir)229 _, err := kvstore.Open(ctx, dir)
217 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)230 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
218- assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept")231+ assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept")
219 })232 })
220 233
221 t.Run("garbage and zero are errors", func(t *testing.T) {234 t.Run("garbage and zero are errors", func(t *testing.T) {
M internal/adapters/kvstore/tokens.go +13 -0
@@ -191,6 +191,11 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
191191 return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
192192 }
193193
194+// encodeToken refuses what the use case already refused (scopes, branch
195+// prefixes): a malformed prefix must never reach the store, where it would
196+// limit a token wrongly or not at all.
197+//
198+//hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy
194199 func encodeToken(t domain.Token) ([]byte, error) {
195200 if !validTokenID(t.ID) {
196201 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
@@ -221,6 +226,12 @@ func encodeToken(t domain.Token) ([]byte, error) {
221226 }
222227 pb.Repos = append(pb.Repos, r.String())
223228 }
229+ for _, bp := range t.BranchPrefixes {
230+ if _, err := domain.ParseBranchPrefix(bp); err != nil {
231+ return nil, err
232+ }
233+ pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
234+ }
224235 return mustMarshal(pb), nil
225236 }
226237
@@ -256,5 +267,7 @@ func decodeToken(val []byte) (domain.Token, error) {
256267 }
257268 t.Repos = append(t.Repos, name)
258269 }
270+ // Kept as stored: a token with prefixes stays limited, whatever they say.
271+ t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
259272 return t, nil
260273 }
@@ -191,6 +191,11 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
191 return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)191 return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
192 }192 }
193 193
194+// encodeToken refuses what the use case already refused (scopes, branch
195+// prefixes): a malformed prefix must never reach the store, where it would
196+// limit a token wrongly or not at all.
197+//
198+//hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy
194 func encodeToken(t domain.Token) ([]byte, error) {199 func encodeToken(t domain.Token) ([]byte, error) {
195 if !validTokenID(t.ID) {200 if !validTokenID(t.ID) {
196 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)201 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
@@ -221,6 +226,12 @@ func encodeToken(t domain.Token) ([]byte, error) {
221 }226 }
222 pb.Repos = append(pb.Repos, r.String())227 pb.Repos = append(pb.Repos, r.String())
223 }228 }
229+ for _, bp := range t.BranchPrefixes {
230+ if _, err := domain.ParseBranchPrefix(bp); err != nil {
231+ return nil, err
232+ }
233+ pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
234+ }
224 return mustMarshal(pb), nil235 return mustMarshal(pb), nil
225 }236 }
226 237
@@ -256,5 +267,7 @@ func decodeToken(val []byte) (domain.Token, error) {
256 }267 }
257 t.Repos = append(t.Repos, name)268 t.Repos = append(t.Repos, name)
258 }269 }
270+ // Kept as stored: a token with prefixes stays limited, whatever they say.
271+ t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
259 return t, nil272 return t, nil
260 }273 }
M internal/adapters/kvstore/tokens_test.go +53 -0
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
1414
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
1516 "bnema.dev/ermite/internal/domain"
1617 )
1718
@@ -192,3 +193,55 @@ func TestTokensConcurrentTouchAndRevoke(t *testing.T) {
192193 assert.Len(t, list, 1)
193194 }
194195 }
196+
197+func TestTokensBranchPrefixes(t *testing.T) {
198+ ctx := context.Background()
199+
200+ t.Run("round trip, order kept", func(t *testing.T) {
201+ s := openStore(t, t.TempDir())
202+ tok := newToken(t, "t1", "s1")
203+ tok.BranchPrefixes = []string{"b/", "agent/claude/"}
204+ require.NoError(t, s.Tokens().Create(ctx, tok))
205+ got, err := s.Tokens().Get(ctx, "t1")
206+ require.NoError(t, err)
207+ assert.Equal(t, tok, got)
208+ byHash, err := s.Tokens().FindByHash(ctx, tok.Hash)
209+ require.NoError(t, err)
210+ assert.Equal(t, []string{"b/", "agent/claude/"}, byHash.BranchPrefixes)
211+ })
212+
213+ t.Run("a token stored before the field existed decodes with no prefixes", func(t *testing.T) {
214+ dir := t.TempDir()
215+ old := newToken(t, "old", "s-old")
216+ putRaw(t, dir, "token/old", mustProto(t, &storagepb.Token{
217+ Id: "old", Label: "old", Hash: old.Hash[:], Scopes: []string{"repo:write"},
218+ }))
219+ s := openStore(t, dir)
220+ got, err := s.Tokens().Get(ctx, "old")
221+ require.NoError(t, err)
222+ assert.Empty(t, got.BranchPrefixes)
223+ assert.Equal(t, []domain.Scope{domain.ScopeRepoWrite}, got.Scopes)
224+ })
225+
226+ t.Run("an invalid prefix is not stored", func(t *testing.T) {
227+ s := openStore(t, t.TempDir())
228+ for _, bad := range []string{"", "agent", "/", "a b/"} {
229+ tok := newToken(t, "t1", "s1")
230+ tok.BranchPrefixes = []string{bad}
231+ assert.ErrorIs(t, s.Tokens().Create(ctx, tok), domain.ErrInvalidToken, "%q", bad)
232+ }
233+ _, err := s.Tokens().Get(ctx, "t1")
234+ assert.ErrorIs(t, err, domain.ErrNotFound)
235+ })
236+
237+ t.Run("a stored prefix survives touch and stays limiting", func(t *testing.T) {
238+ s := openStore(t, t.TempDir())
239+ tok := newToken(t, "t1", "s1")
240+ tok.BranchPrefixes = []string{"agent/"}
241+ require.NoError(t, s.Tokens().Create(ctx, tok))
242+ require.NoError(t, s.Tokens().Touch(ctx, "t1", created.Add(time.Hour)))
243+ got, err := s.Tokens().Get(ctx, "t1")
244+ require.NoError(t, err)
245+ assert.Equal(t, []string{"agent/"}, got.BranchPrefixes)
246+ })
247+}
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
15 "bnema.dev/ermite/internal/domain"16 "bnema.dev/ermite/internal/domain"
16 )17 )
17 18
@@ -192,3 +193,55 @@ func TestTokensConcurrentTouchAndRevoke(t *testing.T) {
192 assert.Len(t, list, 1)193 assert.Len(t, list, 1)
193 }194 }
194 }195 }
196+
197+func TestTokensBranchPrefixes(t *testing.T) {
198+ ctx := context.Background()
199+
200+ t.Run("round trip, order kept", func(t *testing.T) {
201+ s := openStore(t, t.TempDir())
202+ tok := newToken(t, "t1", "s1")
203+ tok.BranchPrefixes = []string{"b/", "agent/claude/"}
204+ require.NoError(t, s.Tokens().Create(ctx, tok))
205+ got, err := s.Tokens().Get(ctx, "t1")
206+ require.NoError(t, err)
207+ assert.Equal(t, tok, got)
208+ byHash, err := s.Tokens().FindByHash(ctx, tok.Hash)
209+ require.NoError(t, err)
210+ assert.Equal(t, []string{"b/", "agent/claude/"}, byHash.BranchPrefixes)
211+ })
212+
213+ t.Run("a token stored before the field existed decodes with no prefixes", func(t *testing.T) {
214+ dir := t.TempDir()
215+ old := newToken(t, "old", "s-old")
216+ putRaw(t, dir, "token/old", mustProto(t, &storagepb.Token{
217+ Id: "old", Label: "old", Hash: old.Hash[:], Scopes: []string{"repo:write"},
218+ }))
219+ s := openStore(t, dir)
220+ got, err := s.Tokens().Get(ctx, "old")
221+ require.NoError(t, err)
222+ assert.Empty(t, got.BranchPrefixes)
223+ assert.Equal(t, []domain.Scope{domain.ScopeRepoWrite}, got.Scopes)
224+ })
225+
226+ t.Run("an invalid prefix is not stored", func(t *testing.T) {
227+ s := openStore(t, t.TempDir())
228+ for _, bad := range []string{"", "agent", "/", "a b/"} {
229+ tok := newToken(t, "t1", "s1")
230+ tok.BranchPrefixes = []string{bad}
231+ assert.ErrorIs(t, s.Tokens().Create(ctx, tok), domain.ErrInvalidToken, "%q", bad)
232+ }
233+ _, err := s.Tokens().Get(ctx, "t1")
234+ assert.ErrorIs(t, err, domain.ErrNotFound)
235+ })
236+
237+ t.Run("a stored prefix survives touch and stays limiting", func(t *testing.T) {
238+ s := openStore(t, t.TempDir())
239+ tok := newToken(t, "t1", "s1")
240+ tok.BranchPrefixes = []string{"agent/"}
241+ require.NoError(t, s.Tokens().Create(ctx, tok))
242+ require.NoError(t, s.Tokens().Touch(ctx, "t1", created.Add(time.Hour)))
243+ got, err := s.Tokens().Get(ctx, "t1")
244+ require.NoError(t, err)
245+ assert.Equal(t, []string{"agent/"}, got.BranchPrefixes)
246+ })
247+}
M internal/adapters/rpc/api.go +1 -1
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
2626
2727 // Features are the optional capabilities of the contract that this server
2828 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}
29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}
3030
3131 // Config configures the API.
3232 type Config struct {
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
26 26
27 // Features are the optional capabilities of the contract that this server27 // Features are the optional capabilities of the contract that this server
28 // implements, as GetVersion lists them.28 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}
30 30
31 // Config configures the API.31 // Config configures the API.
32 type Config struct {32 type Config struct {
M internal/adapters/rpc/convert.go +2 -0
@@ -4,6 +4,7 @@ package rpc
44
55 import (
66 "fmt"
7+ "slices"
78 "time"
89
910 "google.golang.org/protobuf/types/known/timestamppb"
@@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token {
155156 for _, r := range t.Repos {
156157 out.Repos = append(out.Repos, r.String())
157158 }
159+ out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
158160 return out
159161 }
160162
@@ -4,6 +4,7 @@ package rpc
4 4
5 import (5 import (
6 "fmt"6 "fmt"
7+ "slices"
7 "time"8 "time"
8 9
9 "google.golang.org/protobuf/types/known/timestamppb"10 "google.golang.org/protobuf/types/known/timestamppb"
@@ -155,6 +156,7 @@ func tokenToPB(t domain.Token) *pb.Token {
155 for _, r := range t.Repos {156 for _, r := range t.Repos {
156 out.Repos = append(out.Repos, r.String())157 out.Repos = append(out.Repos, r.String())
157 }158 }
159+ out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
158 return out160 return out
159 }161 }
160 162
M internal/adapters/rpc/session.go +14 -0
@@ -4,6 +4,7 @@ package rpc
44
55 import (
66 "context"
7+ "errors"
78
89 "connectrpc.com/connect"
910
@@ -11,6 +12,11 @@ import (
1112 "bnema.dev/ermite/internal/ports"
1213 )
1314
15+// errPasskeysUnavailable is what the passkey RPCs answer: this server has no
16+// passkeys, which the contract reports as failed_precondition. The passkey
17+// feature replaces these two methods.
18+var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
19+
1420 // sessionService implements ermiteconnect.SessionServiceHandler. The browser
1521 // never calls it: sessions come from the link, and the API ignores cookies.
1622 type sessionService struct{ sessions ports.SessionService }
@@ -29,3 +35,11 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque
2935 }
3036 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
3137 }
38+
39+func (s *sessionService) ListPasskeys(context.Context, *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) {
40+ return nil, errPasskeysUnavailable
41+}
42+
43+func (s *sessionService) RemovePasskey(context.Context, *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) {
44+ return nil, errPasskeysUnavailable
45+}
@@ -4,6 +4,7 @@ package rpc
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 8
8 "connectrpc.com/connect"9 "connectrpc.com/connect"
9 10
@@ -11,6 +12,11 @@ import (
11 "bnema.dev/ermite/internal/ports"12 "bnema.dev/ermite/internal/ports"
12 )13 )
13 14
15+// errPasskeysUnavailable is what the passkey RPCs answer: this server has no
16+// passkeys, which the contract reports as failed_precondition. The passkey
17+// feature replaces these two methods.
18+var errPasskeysUnavailable = connect.NewError(connect.CodeFailedPrecondition, errors.New("passkeys are not available on this server"))
19+
14 // sessionService implements ermiteconnect.SessionServiceHandler. The browser20 // sessionService implements ermiteconnect.SessionServiceHandler. The browser
15 // never calls it: sessions come from the link, and the API ignores cookies.21 // never calls it: sessions come from the link, and the API ignores cookies.
16 type sessionService struct{ sessions ports.SessionService }22 type sessionService struct{ sessions ports.SessionService }
@@ -29,3 +35,11 @@ func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Reque
29 }35 }
30 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil36 return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil
31 }37 }
38+
39+func (s *sessionService) ListPasskeys(context.Context, *connect.Request[pb.ListPasskeysRequest]) (*connect.Response[pb.ListPasskeysResponse], error) {
40+ return nil, errPasskeysUnavailable
41+}
42+
43+func (s *sessionService) RemovePasskey(context.Context, *connect.Request[pb.RemovePasskeyRequest]) (*connect.Response[pb.RemovePasskeyResponse], error) {
44+ return nil, errPasskeysUnavailable
45+}
M internal/adapters/rpc/token.go +1 -0
@@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.
3434 }
3535 params.Repos = append(params.Repos, name)
3636 }
37+ params.BranchPrefixes = req.Msg.GetBranchPrefixes()
3738 created, err := s.tokens.Create(ctx, caller(ctx), params)
3839 if err != nil {
3940 return nil, connectError(ctx, err)
@@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.
34 }34 }
35 params.Repos = append(params.Repos, name)35 params.Repos = append(params.Repos, name)
36 }36 }
37+ params.BranchPrefixes = req.Msg.GetBranchPrefixes()
37 created, err := s.tokens.Create(ctx, caller(ctx), params)38 created, err := s.tokens.Create(ctx, caller(ctx), params)
38 if err != nil {39 if err != nil {
39 return nil, connectError(ctx, err)40 return nil, connectError(ctx, err)
M internal/adapters/rpc/token_test.go +31 -0
@@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) {
6464 require.NoError(t, err)
6565 }
6666
67+func TestTokenServiceBranchPrefixes(t *testing.T) {
68+ t.Parallel()
69+ tokens := mocks.NewTokenService(t)
70+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
71+ return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes)
72+ })).Return(ports.CreatedToken{
73+ Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}},
74+ Secret: "s",
75+ }, nil).Once()
76+ resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
77+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"},
78+ }))
79+ require.NoError(t, err)
80+ assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes())
81+
82+ // A malformed prefix is the use case's invalid_token: invalid_argument.
83+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once()
84+ _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
85+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"},
86+ }))
87+ requireCode(t, connect.CodeInvalidArgument, err)
88+
89+ // Listing returns them too; a token without any has none.
90+ tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, "").
91+ Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once()
92+ list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
93+ require.NoError(t, err)
94+ assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes())
95+ assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes())
96+}
97+
6798 func TestTokenServiceRevokeToken(t *testing.T) {
6899 t.Parallel()
69100 tests := []struct {
@@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) {
64 require.NoError(t, err)64 require.NoError(t, err)
65 }65 }
66 66
67+func TestTokenServiceBranchPrefixes(t *testing.T) {
68+ t.Parallel()
69+ tokens := mocks.NewTokenService(t)
70+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
71+ return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes)
72+ })).Return(ports.CreatedToken{
73+ Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}},
74+ Secret: "s",
75+ }, nil).Once()
76+ resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
77+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"},
78+ }))
79+ require.NoError(t, err)
80+ assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes())
81+
82+ // A malformed prefix is the use case's invalid_token: invalid_argument.
83+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once()
84+ _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
85+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"},
86+ }))
87+ requireCode(t, connect.CodeInvalidArgument, err)
88+
89+ // Listing returns them too; a token without any has none.
90+ tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, "").
91+ Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once()
92+ list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
93+ require.NoError(t, err)
94+ assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes())
95+ assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes())
96+}
97+
67 func TestTokenServiceRevokeToken(t *testing.T) {98 func TestTokenServiceRevokeToken(t *testing.T) {
68 t.Parallel()99 t.Parallel()
69 tests := []struct {100 tests := []struct {
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
A internal/app/ci_branch_token_test.go +140 -0
@@ -0,0 +1,140 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const everyBranchWorkflow = `name: all
17+on:
18+ push:
19+ branches: ['**']
20+ tags: ['v*']
21+jobs:
22+ j:
23+ runs-on: ubuntu-latest
24+ steps:
25+ - run: echo hi
26+`
27+
28+// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29+// logs is not a write and stays open to whoever can read the repository.
30+// Cancelling and re-running a run is a write on the ref of the run (it stops or
31+// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32+// branch under one, refused on any other branch and on a tag. Runners serve
33+// every repository and branch, so a limited token never manages them, even with
34+// runner:register.
35+func TestCIBranchLimitedToken(t *testing.T) {
36+ c := newCIHarness(t, nil)
37+ runner := c.registerRunner("alpha", "ubuntu-latest")
38+ wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39+
40+ c.push("main", wf, "")
41+ c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43+ c.push("agent/claude/x", nil, "")
44+ c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46+ c.mustGit(c.wt, "tag", "v1")
47+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48+ c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49+ c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50+
51+ var mainRun, agentRun, tagRun *pb.Run
52+ for _, r := range c.runs() {
53+ switch r.GetRef() {
54+ case "refs/heads/main":
55+ mainRun = r
56+ case "refs/heads/agent/claude/x":
57+ agentRun = r
58+ case "refs/tags/v1":
59+ tagRun = r
60+ }
61+ }
62+ require.NotNil(t, mainRun)
63+ require.NotNil(t, agentRun)
64+ require.NotNil(t, tagRun)
65+
66+ all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67+ secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68+ limited := c.api(secret)
69+ // The same token without the limit, to prove the refusals come from it.
70+ unlimited := c.api(c.token(all, "proj"))
71+ cancel := func(api *clients, r *pb.Run) error {
72+ _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73+ return err
74+ }
75+ rerun := func(api *clients, r *pb.Run) error {
76+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77+ return err
78+ }
79+
80+ t.Run("reads", func(t *testing.T) {
81+ list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82+ require.NoError(t, err)
83+ assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84+ got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85+ require.NoError(t, err)
86+ require.NotEmpty(t, got.Msg.GetJobs())
87+ log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88+ require.NoError(t, err)
89+ var text string
90+ for log.Receive() {
91+ for _, l := range log.Msg().GetLines() {
92+ text += l.GetText()
93+ }
94+ }
95+ require.NoError(t, log.Err())
96+ assert.Contains(t, text, "log of main")
97+ _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98+ require.NoError(t, err)
99+ })
100+
101+ t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102+ for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105+ }
106+ // Still there: the refused rerun started no new attempt.
107+ assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108+ // Without the limit the same token is allowed (rerun) or reaches the
109+ // state rule of the run (cancel of a completed run).
110+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111+ require.NoError(t, rerun(unlimited, mainRun))
112+ })
113+
114+ t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115+ require.NoError(t, rerun(limited, agentRun))
116+ assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117+ require.NoError(t, cancel(limited, agentRun))
118+ })
119+
120+ t.Run("runners are out of reach", func(t *testing.T) {
121+ // Not restricted to a repository, so only the branch limit can refuse it.
122+ secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123+ limited := c.api(secret)
124+ _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125+ Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126+ }))
127+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128+ _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130+ _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132+
133+ // The same scopes without the limit manage runners (not restricted to a
134+ // repository, which the runner rules already demand).
135+ open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136+ list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137+ require.NoError(t, err)
138+ assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139+ })
140+}
@@ -0,0 +1,140 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const everyBranchWorkflow = `name: all
17+on:
18+ push:
19+ branches: ['**']
20+ tags: ['v*']
21+jobs:
22+ j:
23+ runs-on: ubuntu-latest
24+ steps:
25+ - run: echo hi
26+`
27+
28+// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29+// logs is not a write and stays open to whoever can read the repository.
30+// Cancelling and re-running a run is a write on the ref of the run (it stops or
31+// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32+// branch under one, refused on any other branch and on a tag. Runners serve
33+// every repository and branch, so a limited token never manages them, even with
34+// runner:register.
35+func TestCIBranchLimitedToken(t *testing.T) {
36+ c := newCIHarness(t, nil)
37+ runner := c.registerRunner("alpha", "ubuntu-latest")
38+ wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39+
40+ c.push("main", wf, "")
41+ c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43+ c.push("agent/claude/x", nil, "")
44+ c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46+ c.mustGit(c.wt, "tag", "v1")
47+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48+ c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49+ c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50+
51+ var mainRun, agentRun, tagRun *pb.Run
52+ for _, r := range c.runs() {
53+ switch r.GetRef() {
54+ case "refs/heads/main":
55+ mainRun = r
56+ case "refs/heads/agent/claude/x":
57+ agentRun = r
58+ case "refs/tags/v1":
59+ tagRun = r
60+ }
61+ }
62+ require.NotNil(t, mainRun)
63+ require.NotNil(t, agentRun)
64+ require.NotNil(t, tagRun)
65+
66+ all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67+ secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68+ limited := c.api(secret)
69+ // The same token without the limit, to prove the refusals come from it.
70+ unlimited := c.api(c.token(all, "proj"))
71+ cancel := func(api *clients, r *pb.Run) error {
72+ _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73+ return err
74+ }
75+ rerun := func(api *clients, r *pb.Run) error {
76+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77+ return err
78+ }
79+
80+ t.Run("reads", func(t *testing.T) {
81+ list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82+ require.NoError(t, err)
83+ assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84+ got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85+ require.NoError(t, err)
86+ require.NotEmpty(t, got.Msg.GetJobs())
87+ log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88+ require.NoError(t, err)
89+ var text string
90+ for log.Receive() {
91+ for _, l := range log.Msg().GetLines() {
92+ text += l.GetText()
93+ }
94+ }
95+ require.NoError(t, log.Err())
96+ assert.Contains(t, text, "log of main")
97+ _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98+ require.NoError(t, err)
99+ })
100+
101+ t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102+ for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105+ }
106+ // Still there: the refused rerun started no new attempt.
107+ assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108+ // Without the limit the same token is allowed (rerun) or reaches the
109+ // state rule of the run (cancel of a completed run).
110+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111+ require.NoError(t, rerun(unlimited, mainRun))
112+ })
113+
114+ t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115+ require.NoError(t, rerun(limited, agentRun))
116+ assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117+ require.NoError(t, cancel(limited, agentRun))
118+ })
119+
120+ t.Run("runners are out of reach", func(t *testing.T) {
121+ // Not restricted to a repository, so only the branch limit can refuse it.
122+ secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123+ limited := c.api(secret)
124+ _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125+ Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126+ }))
127+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128+ _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130+ _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132+
133+ // The same scopes without the limit manage runners (not restricted to a
134+ // repository, which the runner rules already demand).
135+ open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136+ list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137+ require.NoError(t, err)
138+ assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139+ })
140+}
M internal/app/cli_e2e_test.go +3 -3
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
@@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
336336 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
337337 require.Equal(t, 0, code, stderr)
338338 res = result{stdout: stdout}
339- assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
339+ assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
340340 tmp := decode[tokenOut](t, res.stdout)
341341 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
342342 assert.Equal(t, []string{"proj"}, tmp.Repos)
@@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
366366 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
367367 assert.NotContains(t, res.stdout, `"secret"`)
368368 res = c.cli(t, secret, "token", "list").ok(t)
369- assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)
369+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout)
370370 assert.Contains(t, res.stdout, tmp.ID)
371371
372372 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324 ver := decode[versionOut](t, res.stdout)324 ver := decode[versionOut](t, res.stdout)
325 assert.Equal(t, c.url, ver.Server.URL)325 assert.Equal(t, c.url, ver.Server.URL)
326 assert.NotEmpty(t, ver.Server.Version)326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329 stdout, stderr, code = c.bin(t, secret, "version")329 stdout, stderr, code = c.bin(t, secret, "version")
330 require.Equal(t, 0, code, stderr)330 require.Equal(t, 0, code, stderr)
@@ -336,7 +336,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
336 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")336 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
337 require.Equal(t, 0, code, stderr)337 require.Equal(t, 0, code, stderr)
338 res = result{stdout: stdout}338 res = result{stdout: stdout}
339- assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))339+ assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
340 tmp := decode[tokenOut](t, res.stdout)340 tmp := decode[tokenOut](t, res.stdout)
341 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)341 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
342 assert.Equal(t, []string{"proj"}, tmp.Repos)342 assert.Equal(t, []string{"proj"}, tmp.Repos)
@@ -366,7 +366,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
366 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)366 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
367 assert.NotContains(t, res.stdout, `"secret"`)367 assert.NotContains(t, res.stdout, `"secret"`)
368 res = c.cli(t, secret, "token", "list").ok(t)368 res = c.cli(t, secret, "token", "list").ok(t)
369- assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)369+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout)
370 assert.Contains(t, res.stdout, tmp.ID)370 assert.Contains(t, res.stdout, tmp.ID)
371 371
372 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)372 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
M internal/app/cli_token.go +81 -19
@@ -21,13 +21,15 @@ import (
2121 // jsonToken is the stable JSON shape of a stored token. The secret is only
2222 // present in the output of `token create`.
2323 type jsonToken struct {
24- ID string `json:"id"`
25- Label string `json:"label"`
26- Scopes []string `json:"scopes"`
27- Repos []string `json:"repos"`
28- ExpiresAt *string `json:"expires_at"`
29- CreatedAt *string `json:"created_at"`
30- LastUsedAt *string `json:"last_used_at"`
24+ ID string `json:"id"`
25+ Label string `json:"label"`
26+ Scopes []string `json:"scopes"`
27+ Repos []string `json:"repos"`
28+ // BranchPrefixes limits the branches the token may write; empty means all.
29+ BranchPrefixes []string `json:"branch_prefixes"`
30+ ExpiresAt *string `json:"expires_at"`
31+ CreatedAt *string `json:"created_at"`
32+ LastUsedAt *string `json:"last_used_at"`
3133 }
3234
3335 type jsonCreatedToken struct {
@@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken {
4446 if repos == nil {
4547 repos = []string{}
4648 }
49+ prefixes := t.GetBranchPrefixes()
50+ if prefixes == nil {
51+ prefixes = []string{}
52+ }
4753 return jsonToken{
48- ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,
54+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
4955 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
5056 }
5157 }
5258
5359 const jsonTokenHelp = `
5460 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55-repos (array; empty means every repository), expires_at, created_at and last_used_at
56-(RFC 3339 UTC, null when unset or never used).`
61+repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
62+expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
5763
5864 func newTokenCmd(env *cliEnv) *cobra.Command {
5965 token := &cobra.Command{
@@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command {
6773
6874 func newTokenCreateCmd(env *cliEnv) *cobra.Command {
6975 var (
70- preset string
71- scopes []string
72- repos []string
73- expires string
76+ preset string
77+ scopes []string
78+ repos []string
79+ prefixes []string
80+ expires string
7481 )
7582 cmd := &cobra.Command{
7683 Use: "create <label>",
@@ -85,6 +92,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8592 " ci repo:read\n" +
8693 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
8794 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
95+ "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
96+ "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
97+ "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
98+ "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
99+ "To give an AI agent its own space:\n" +
100+ " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
88101 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
89102 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
90103 Args: cobra.ExactArgs(1),
@@ -92,6 +105,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
92105 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
93106 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
94107 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
108+ cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
95109 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
96110 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
97111 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
@@ -104,7 +118,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
104118 })
105119 asJSON := addJSONFlag(cmd)
106120 cmd.RunE = func(cmd *cobra.Command, args []string) error {
107- req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())
121+ req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
108122 if err != nil {
109123 return err
110124 }
@@ -112,17 +126,51 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
112126 if err != nil {
113127 return err
114128 }
129+ if len(req.GetBranchPrefixes()) > 0 {
130+ if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
131+ return err
132+ }
133+ }
115134 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
116135 if err != nil {
117136 return c.Describe(err)
118137 }
138+ if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
139+ return err
140+ }
119141 return printCreatedToken(cmd, res.Msg, *asJSON)
120142 }
121143 return cmd
122144 }
123145
146+// requireBranchPrefixes refuses, before anything is created, to ask a server
147+// for a branch-limited token when it does not list token.branch_prefixes: such
148+// a server ignores the field. A server whose features cannot be read is refused
149+// too (fail closed).
150+func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
151+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
152+ if err != nil {
153+ return c.Describe(err)
154+ }
155+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
156+ return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes)
157+ }
158+ return nil
159+}
160+
161+// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
162+// asked for. A server that predates branch prefixes ignores the field and
163+// creates an unlimited token: the secret is then not printed.
164+func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
165+ if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
166+ return nil
167+ }
168+ return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
169+ "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
170+}
171+
124172 // buildCreateToken maps the flags of `token create` to the request.
125-func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
173+func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
126174 label = strings.TrimSpace(label)
127175 if label == "" {
128176 return nil, errors.New("the label must not be empty")
@@ -137,6 +185,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi
137185 req.Repos = append(req.Repos, r)
138186 }
139187 }
188+ for _, bp := range prefixFlags {
189+ if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
190+ req.BranchPrefixes = append(req.BranchPrefixes, bp)
191+ }
192+ }
140193 if expires != "" {
141194 at, err := client.ParseExpiry(expires, now)
142195 if err != nil {
@@ -156,10 +209,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS
156209 if len(t.GetRepos()) > 0 {
157210 repos = strings.Join(t.GetRepos(), ", ")
158211 }
212+ branches := "all"
213+ if len(t.GetBranchPrefixes()) > 0 {
214+ branches = strings.Join(t.GetBranchPrefixes(), ", ")
215+ }
159216 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
160217 _ = fields(cmd.ErrOrStderr(), [][2]string{
161218 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
162219 {" repos", repos},
220+ {" branches", branches},
163221 {" expires", shortTime(t.GetExpiresAt(), "never")},
164222 })
165223 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
@@ -211,12 +269,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
211269 if len(t.GetRepos()) > 0 {
212270 repos = strings.Join(t.GetRepos(), ",")
213271 }
272+ branches := "all"
273+ if len(t.GetBranchPrefixes()) > 0 {
274+ branches = strings.Join(t.GetBranchPrefixes(), ",")
275+ }
214276 rows = append(rows, []string{
215- t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,
277+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
216278 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
217279 })
218280 }
219- return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)
281+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
220282 }
221283 return cmd
222284 }
@@ -226,7 +288,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
226288 Use: "revoke <id>",
227289 Short: "Revoke a token",
228290 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
229- "A token with every scope and no --repo list has the rights of the owner (it can open web\n" +
291+ "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
230292 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
231293 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
232294 Args: cobra.ExactArgs(1),
@@ -21,13 +21,15 @@ import (
21 // jsonToken is the stable JSON shape of a stored token. The secret is only21 // jsonToken is the stable JSON shape of a stored token. The secret is only
22 // present in the output of `token create`.22 // present in the output of `token create`.
23 type jsonToken struct {23 type jsonToken struct {
24- ID string `json:"id"`24+ ID string `json:"id"`
25- Label string `json:"label"`25+ Label string `json:"label"`
26- Scopes []string `json:"scopes"`26+ Scopes []string `json:"scopes"`
27- Repos []string `json:"repos"`27+ Repos []string `json:"repos"`
28- ExpiresAt *string `json:"expires_at"`28+ // BranchPrefixes limits the branches the token may write; empty means all.
29- CreatedAt *string `json:"created_at"`29+ BranchPrefixes []string `json:"branch_prefixes"`
30- LastUsedAt *string `json:"last_used_at"`30+ ExpiresAt *string `json:"expires_at"`
31+ CreatedAt *string `json:"created_at"`
32+ LastUsedAt *string `json:"last_used_at"`
31 }33 }
32 34
33 type jsonCreatedToken struct {35 type jsonCreatedToken struct {
@@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken {
44 if repos == nil {46 if repos == nil {
45 repos = []string{}47 repos = []string{}
46 }48 }
49+ prefixes := t.GetBranchPrefixes()
50+ if prefixes == nil {
51+ prefixes = []string{}
52+ }
47 return jsonToken{53 return jsonToken{
48- ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,54+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
49 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),55 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
50 }56 }
51 }57 }
52 58
53 const jsonTokenHelp = `59 const jsonTokenHelp = `
54 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),60 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55-repos (array; empty means every repository), expires_at, created_at and last_used_at61+repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
56-(RFC 3339 UTC, null when unset or never used).`62+expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
57 63
58 func newTokenCmd(env *cliEnv) *cobra.Command {64 func newTokenCmd(env *cliEnv) *cobra.Command {
59 token := &cobra.Command{65 token := &cobra.Command{
@@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command {
67 73
68 func newTokenCreateCmd(env *cliEnv) *cobra.Command {74 func newTokenCreateCmd(env *cliEnv) *cobra.Command {
69 var (75 var (
70- preset string76+ preset string
71- scopes []string77+ scopes []string
72- repos []string78+ repos []string
73- expires string79+ prefixes []string
80+ expires string
74 )81 )
75 cmd := &cobra.Command{82 cmd := &cobra.Command{
76 Use: "create <label>",83 Use: "create <label>",
@@ -85,6 +92,12 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
85 " ci repo:read\n" +92 " ci repo:read\n" +
86 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +93 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
87 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +94 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
95+ "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
96+ "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
97+ "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
98+ "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
99+ "To give an AI agent its own space:\n" +
100+ " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
88 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +101 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
89 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,102 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
90 Args: cobra.ExactArgs(1),103 Args: cobra.ExactArgs(1),
@@ -92,6 +105,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
92 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))105 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
93 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")106 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
94 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")107 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
108+ cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
95 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")109 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
96 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {110 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
97 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp111 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
@@ -104,7 +118,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
104 })118 })
105 asJSON := addJSONFlag(cmd)119 asJSON := addJSONFlag(cmd)
106 cmd.RunE = func(cmd *cobra.Command, args []string) error {120 cmd.RunE = func(cmd *cobra.Command, args []string) error {
107- req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())121+ req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
108 if err != nil {122 if err != nil {
109 return err123 return err
110 }124 }
@@ -112,17 +126,51 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
112 if err != nil {126 if err != nil {
113 return err127 return err
114 }128 }
129+ if len(req.GetBranchPrefixes()) > 0 {
130+ if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
131+ return err
132+ }
133+ }
115 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))134 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
116 if err != nil {135 if err != nil {
117 return c.Describe(err)136 return c.Describe(err)
118 }137 }
138+ if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
139+ return err
140+ }
119 return printCreatedToken(cmd, res.Msg, *asJSON)141 return printCreatedToken(cmd, res.Msg, *asJSON)
120 }142 }
121 return cmd143 return cmd
122 }144 }
123 145
146+// requireBranchPrefixes refuses, before anything is created, to ask a server
147+// for a branch-limited token when it does not list token.branch_prefixes: such
148+// a server ignores the field. A server whose features cannot be read is refused
149+// too (fail closed).
150+func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
151+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
152+ if err != nil {
153+ return c.Describe(err)
154+ }
155+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
156+ return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes)
157+ }
158+ return nil
159+}
160+
161+// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
162+// asked for. A server that predates branch prefixes ignores the field and
163+// creates an unlimited token: the secret is then not printed.
164+func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
165+ if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
166+ return nil
167+ }
168+ return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
169+ "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
170+}
171+
124 // buildCreateToken maps the flags of `token create` to the request.172 // buildCreateToken maps the flags of `token create` to the request.
125-func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {173+func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
126 label = strings.TrimSpace(label)174 label = strings.TrimSpace(label)
127 if label == "" {175 if label == "" {
128 return nil, errors.New("the label must not be empty")176 return nil, errors.New("the label must not be empty")
@@ -137,6 +185,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi
137 req.Repos = append(req.Repos, r)185 req.Repos = append(req.Repos, r)
138 }186 }
139 }187 }
188+ for _, bp := range prefixFlags {
189+ if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
190+ req.BranchPrefixes = append(req.BranchPrefixes, bp)
191+ }
192+ }
140 if expires != "" {193 if expires != "" {
141 at, err := client.ParseExpiry(expires, now)194 at, err := client.ParseExpiry(expires, now)
142 if err != nil {195 if err != nil {
@@ -156,10 +209,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS
156 if len(t.GetRepos()) > 0 {209 if len(t.GetRepos()) > 0 {
157 repos = strings.Join(t.GetRepos(), ", ")210 repos = strings.Join(t.GetRepos(), ", ")
158 }211 }
212+ branches := "all"
213+ if len(t.GetBranchPrefixes()) > 0 {
214+ branches = strings.Join(t.GetBranchPrefixes(), ", ")
215+ }
159 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))216 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
160 _ = fields(cmd.ErrOrStderr(), [][2]string{217 _ = fields(cmd.ErrOrStderr(), [][2]string{
161 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},218 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
162 {" repos", repos},219 {" repos", repos},
220+ {" branches", branches},
163 {" expires", shortTime(t.GetExpiresAt(), "never")},221 {" expires", shortTime(t.GetExpiresAt(), "never")},
164 })222 })
165 noticef(cmd, "the secret below is shown once and cannot be retrieved later")223 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
@@ -211,12 +269,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
211 if len(t.GetRepos()) > 0 {269 if len(t.GetRepos()) > 0 {
212 repos = strings.Join(t.GetRepos(), ",")270 repos = strings.Join(t.GetRepos(), ",")
213 }271 }
272+ branches := "all"
273+ if len(t.GetBranchPrefixes()) > 0 {
274+ branches = strings.Join(t.GetBranchPrefixes(), ",")
275+ }
214 rows = append(rows, []string{276 rows = append(rows, []string{
215- t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,277+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
216 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),278 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
217 })279 })
218 }280 }
219- return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)281+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
220 }282 }
221 return cmd283 return cmd
222 }284 }
@@ -226,7 +288,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
226 Use: "revoke <id>",288 Use: "revoke <id>",
227 Short: "Revoke a token",289 Short: "Revoke a token",
228 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +290 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
229- "A token with every scope and no --repo list has the rights of the owner (it can open web\n" +291+ "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
230 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +292 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
231 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",293 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
232 Args: cobra.ExactArgs(1),294 Args: cobra.ExactArgs(1),
M internal/app/cli_token_test.go +131 -6
@@ -3,9 +3,12 @@
33 package app
44
55 import (
6+ "errors"
7+ "strings"
68 "testing"
79 "time"
810
11+ "connectrpc.com/connect"
912 "github.com/stretchr/testify/assert"
1013 "github.com/stretchr/testify/require"
1114
@@ -42,6 +45,128 @@ func TestTokenCreateMapsFlags(t *testing.T) {
4245 assert.NotContains(t, res.stderr, "SECRETVALUE")
4346 }
4447
48+// lastCreateToken returns the last CreateToken request the fake received.
49+func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest {
50+ t.Helper()
51+ calls, reqs := f.snapshot()
52+ for i := len(calls) - 1; i >= 0; i-- {
53+ if calls[i] == "CreateToken" {
54+ return reqs[i].(*ermite.CreateTokenRequest)
55+ }
56+ }
57+ t.Fatal("no CreateToken call")
58+ return nil
59+}
60+
61+// withBranchPrefixes is a GetVersion that lists token.branch_prefixes.
62+func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
63+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil
64+}
65+
66+func TestTokenCreateBranchPrefixes(t *testing.T) {
67+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
68+ return &ermite.CreateTokenResponse{
69+ Token: &ermite.Token{
70+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(),
71+ CreatedAt: ts("2026-10-10T12:00:00Z"),
72+ },
73+ Secret: "ermite_S",
74+ }, nil
75+ }
76+
77+ t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) {
78+ f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
79+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha",
80+ "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t)
81+ assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes())
82+ assert.Equal(t, "ermite_S\n", res.stdout)
83+ assert.Contains(t, res.stderr, "branches")
84+ assert.Contains(t, res.stderr, "agent/claude/, bot/")
85+ })
86+
87+ t.Run("without the flag the token shows every branch", func(t *testing.T) {
88+ f := &fakeAPI{createToken: echo}
89+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
90+ _, reqs := f.snapshot()
91+ assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
92+ assert.Regexp(t, `branches\s+all`, res.stderr)
93+ })
94+
95+ t.Run("json", func(t *testing.T) {
96+ f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
97+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t)
98+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
99+ })
100+
101+ t.Run("a server without the feature is asked nothing", func(t *testing.T) {
102+ for _, tt := range []struct {
103+ name string
104+ version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
105+ wantMsg string
106+ }{
107+ {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
108+ return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil
109+ }, "does not support token.branch_prefixes"},
110+ {"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented
111+ {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
112+ return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused"))
113+ }, "cannot reach the server"},
114+ } {
115+ f := &fakeAPI{createToken: echo, getVersion: tt.version}
116+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
117+ require.Error(t, res.err, tt.name)
118+ assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name)
119+ calls, _ := f.snapshot()
120+ assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name)
121+ assert.Empty(t, res.stdout, tt.name)
122+ }
123+ })
124+
125+ t.Run("without --branch-prefix the features are not read", func(t *testing.T) {
126+ f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented
127+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
128+ calls, _ := f.snapshot()
129+ assert.NotContains(t, calls, "GetVersion")
130+ })
131+
132+ t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) {
133+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
134+ return &ermite.CreateTokenResponse{
135+ Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
136+ Secret: "ermite_UNLIMITED",
137+ }, nil
138+ }, getVersion: withBranchPrefixes}
139+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
140+ require.Error(t, res.err)
141+ assert.Contains(t, res.err.Error(), "without the requested branch prefixes")
142+ assert.Contains(t, res.err.Error(), "ermite token revoke tok9")
143+ assert.NotContains(t, res.stdout, "UNLIMITED")
144+ assert.NotContains(t, res.stderr, "UNLIMITED")
145+ assert.NotContains(t, res.err.Error(), "UNLIMITED")
146+ })
147+}
148+
149+func TestTokenListBranchPrefixes(t *testing.T) {
150+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
151+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
152+ {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")},
153+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
154+ }}, nil
155+ }}
156+ url := f.serve(t)
157+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
158+ assert.Equal(t, ""+
159+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
160+ "t2 claude repo:write all agent/claude/,bot/ never never\n"+
161+ "t1 ci repo:read all all never never\n", res.stdout)
162+
163+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
164+ assert.JSONEq(t, `[
165+ {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null},
166+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
167+ ]`, res.stdout)
168+}
169+
45170 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46171 for preset, want := range map[string][]ermite.Scope{
47172 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
@@ -70,7 +195,7 @@ func TestTokenCreateJSON(t *testing.T) {
70195 }, nil
71196 }}
72197 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73- assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
198+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74199 assert.Empty(t, res.stderr)
75200 }
76201
@@ -106,14 +231,14 @@ func TestTokenList(t *testing.T) {
106231 url := f.serve(t)
107232 res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108233 assert.Equal(t, ""+
109- "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+
110- "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+
111- "t1 ci repo:read all never never\n", res.stdout)
234+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
235+ "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+
236+ "t1 ci repo:read all all never never\n", res.stdout)
112237
113238 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114239 assert.JSONEq(t, `[
115- {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116- {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
240+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
241+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117242 ]`, res.stdout)
118243 assert.NotContains(t, res.stdout, "secret")
119244 }
@@ -3,9 +3,12 @@
3 package app3 package app
4 4
5 import (5 import (
6+ "errors"
7+ "strings"
6 "testing"8 "testing"
7 "time"9 "time"
8 10
11+ "connectrpc.com/connect"
9 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
11 14
@@ -42,6 +45,128 @@ func TestTokenCreateMapsFlags(t *testing.T) {
42 assert.NotContains(t, res.stderr, "SECRETVALUE")45 assert.NotContains(t, res.stderr, "SECRETVALUE")
43 }46 }
44 47
48+// lastCreateToken returns the last CreateToken request the fake received.
49+func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest {
50+ t.Helper()
51+ calls, reqs := f.snapshot()
52+ for i := len(calls) - 1; i >= 0; i-- {
53+ if calls[i] == "CreateToken" {
54+ return reqs[i].(*ermite.CreateTokenRequest)
55+ }
56+ }
57+ t.Fatal("no CreateToken call")
58+ return nil
59+}
60+
61+// withBranchPrefixes is a GetVersion that lists token.branch_prefixes.
62+func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
63+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil
64+}
65+
66+func TestTokenCreateBranchPrefixes(t *testing.T) {
67+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
68+ return &ermite.CreateTokenResponse{
69+ Token: &ermite.Token{
70+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(),
71+ CreatedAt: ts("2026-10-10T12:00:00Z"),
72+ },
73+ Secret: "ermite_S",
74+ }, nil
75+ }
76+
77+ t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) {
78+ f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
79+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha",
80+ "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t)
81+ assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes())
82+ assert.Equal(t, "ermite_S\n", res.stdout)
83+ assert.Contains(t, res.stderr, "branches")
84+ assert.Contains(t, res.stderr, "agent/claude/, bot/")
85+ })
86+
87+ t.Run("without the flag the token shows every branch", func(t *testing.T) {
88+ f := &fakeAPI{createToken: echo}
89+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
90+ _, reqs := f.snapshot()
91+ assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
92+ assert.Regexp(t, `branches\s+all`, res.stderr)
93+ })
94+
95+ t.Run("json", func(t *testing.T) {
96+ f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
97+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t)
98+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
99+ })
100+
101+ t.Run("a server without the feature is asked nothing", func(t *testing.T) {
102+ for _, tt := range []struct {
103+ name string
104+ version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
105+ wantMsg string
106+ }{
107+ {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
108+ return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil
109+ }, "does not support token.branch_prefixes"},
110+ {"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented
111+ {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
112+ return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused"))
113+ }, "cannot reach the server"},
114+ } {
115+ f := &fakeAPI{createToken: echo, getVersion: tt.version}
116+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
117+ require.Error(t, res.err, tt.name)
118+ assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name)
119+ calls, _ := f.snapshot()
120+ assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name)
121+ assert.Empty(t, res.stdout, tt.name)
122+ }
123+ })
124+
125+ t.Run("without --branch-prefix the features are not read", func(t *testing.T) {
126+ f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented
127+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
128+ calls, _ := f.snapshot()
129+ assert.NotContains(t, calls, "GetVersion")
130+ })
131+
132+ t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) {
133+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
134+ return &ermite.CreateTokenResponse{
135+ Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
136+ Secret: "ermite_UNLIMITED",
137+ }, nil
138+ }, getVersion: withBranchPrefixes}
139+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
140+ require.Error(t, res.err)
141+ assert.Contains(t, res.err.Error(), "without the requested branch prefixes")
142+ assert.Contains(t, res.err.Error(), "ermite token revoke tok9")
143+ assert.NotContains(t, res.stdout, "UNLIMITED")
144+ assert.NotContains(t, res.stderr, "UNLIMITED")
145+ assert.NotContains(t, res.err.Error(), "UNLIMITED")
146+ })
147+}
148+
149+func TestTokenListBranchPrefixes(t *testing.T) {
150+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
151+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
152+ {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")},
153+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
154+ }}, nil
155+ }}
156+ url := f.serve(t)
157+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
158+ assert.Equal(t, ""+
159+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
160+ "t2 claude repo:write all agent/claude/,bot/ never never\n"+
161+ "t1 ci repo:read all all never never\n", res.stdout)
162+
163+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
164+ assert.JSONEq(t, `[
165+ {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null},
166+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
167+ ]`, res.stdout)
168+}
169+
45 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {170 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46 for preset, want := range map[string][]ermite.Scope{171 for preset, want := range map[string][]ermite.Scope{
47 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},172 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
@@ -70,7 +195,7 @@ func TestTokenCreateJSON(t *testing.T) {
70 }, nil195 }, nil
71 }}196 }}
72 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)197 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73- assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)198+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74 assert.Empty(t, res.stderr)199 assert.Empty(t, res.stderr)
75 }200 }
76 201
@@ -106,14 +231,14 @@ func TestTokenList(t *testing.T) {
106 url := f.serve(t)231 url := f.serve(t)
107 res := runCLI(t, envFor(url), "", "token", "list").ok(t)232 res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108 assert.Equal(t, ""+233 assert.Equal(t, ""+
109- "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+234+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
110- "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+235+ "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+
111- "t1 ci repo:read all never never\n", res.stdout)236+ "t1 ci repo:read all all never never\n", res.stdout)
112 237
113 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)238 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114 assert.JSONEq(t, `[239 assert.JSONEq(t, `[
115- {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},240+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116- {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}241+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117 ]`, res.stdout)242 ]`, res.stdout)
118 assert.NotContains(t, res.stdout, "secret")243 assert.NotContains(t, res.stdout, "secret")
119 }244 }
M internal/app/cli_version.go +7 -6
@@ -109,10 +109,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
109109
110110 // featureUse says what breaks without a feature, for the version warning.
111111 var featureUse = map[string]string{
112- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115- client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
112+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118+ client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
118119 }
@@ -109,10 +109,11 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
109 109
110 // featureUse says what breaks without a feature, for the version warning.110 // featureUse says what breaks without a feature, for the version warning.
111 var featureUse = map[string]string{111 var featureUse = map[string]string{
112- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",112+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",113+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",114+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115- client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",115+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",116+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",117+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118+ client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
118 }119 }
M internal/app/cli_version_test.go +5 -5
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"}
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners"}
1818
1919 func versionFake(features ...string) *fakeAPI {
2020 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
3535 f := versionFake(allFeatures...)
3636 url := f.serve(t)
3737 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout)
38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners\n", res.stdout)
3939 assert.Empty(t, res.stderr, "no warning when every feature is there")
4040
4141 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
4343 }
4444
4545 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners")
46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners")
4747 url := f.serve(t)
4848 res := runCLI(t, envFor(url), "", "version").ok(t)
4949 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5151 assert.NotContains(t, res.stderr, "repo.update")
5252
5353 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
5555 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5656
5757 f = versionFake()
@@ -14,7 +14,7 @@ import (
14 )14 )
15 15
16 // allFeatures are the features this client uses.16 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"}17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners"}
18 18
19 func versionFake(features ...string) *fakeAPI {19 func versionFake(features ...string) *fakeAPI {
20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {20 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
35 f := versionFake(allFeatures...)35 f := versionFake(allFeatures...)
36 url := f.serve(t)36 url := f.serve(t)
37 res := runCLI(t, envFor(url), "", "version").ok(t)37 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout)38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners\n", res.stdout)
39 assert.Empty(t, res.stderr, "no warning when every feature is there")39 assert.Empty(t, res.stderr, "no warning when every feature is there")
40 40
41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)41 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
43 }43 }
44 44
45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {45 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners")46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners")
47 url := f.serve(t)47 url := f.serve(t)
48 res := runCLI(t, envFor(url), "", "version").ok(t)48 res := runCLI(t, envFor(url), "", "version").ok(t)
49 assert.Contains(t, res.stdout, "v0.4.0")49 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
51 assert.NotContains(t, res.stderr, "repo.update")51 assert.NotContains(t, res.stderr, "repo.update")
52 52
53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)53 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")55 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
56 56
57 f = versionFake()57 f = versionFake()
M internal/app/cli_web.go +1 -1
@@ -23,7 +23,7 @@ func newWebCmd(env *cliEnv) *cobra.Command {
2323 Use: "web",
2424 Short: "Sign the owner in to the web interface of a remote server",
2525 Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +
26- "token that holds every scope and is not limited to repositories.",
26+ "token that holds every scope and is limited neither to repositories nor to branches.",
2727 }
2828 web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))
2929 return web
@@ -23,7 +23,7 @@ func newWebCmd(env *cliEnv) *cobra.Command {
23 Use: "web",23 Use: "web",
24 Short: "Sign the owner in to the web interface of a remote server",24 Short: "Sign the owner in to the web interface of a remote server",
25 Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +25 Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" +
26- "token that holds every scope and is not limited to repositories.",26+ "token that holds every scope and is limited neither to repositories nor to branches.",
27 }27 }
28 web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))28 web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env))
29 return web29 return web
A internal/app/e2e_branches_test.go +212 -0
@@ -0,0 +1,212 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// branchToken creates a stored token limited to branch prefixes, as root.
21+func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) {
22+ h.t.Helper()
23+ p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes}
24+ for _, r := range repos {
25+ p.Repos = append(p.Repos, h.name(r))
26+ }
27+ got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
28+ require.NoError(h.t, err)
29+ return got.Secret, got.Token.ID
30+}
31+
32+// AC5: a token limited to branch prefixes, through real git pushes and the API.
33+func TestAC5BranchLimitedToken(t *testing.T) {
34+ h := newHarness(t, harnessOpts{})
35+ wt := h.workTree("main", "a.txt")
36+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
37+ require.NoError(t, err, out)
38+ mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
39+
40+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge),
41+ []string{"agent/claude/"}, "proj")
42+ remote := h.urlAuth(secret, "/proj.git")
43+ commit := func(file string) {
44+ require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
45+ h.mustGit(wt, "add", file)
46+ h.mustGit(wt, "commit", "-q", "-m", file)
47+ }
48+ refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
49+
50+ // It pushes, force-pushes and deletes under its prefix.
51+ h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x")
52+ commit("x1")
53+ out, err = h.git(wt, "push", "-q", remote, "agent/claude/x")
54+ require.NoError(t, err, out)
55+ assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
56+
57+ h.mustGit(wt, "reset", "-q", "--hard", "main")
58+ commit("x2")
59+ out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x")
60+ require.NoError(t, err, out)
61+ assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
62+
63+ h.mustGit(wt, "branch", "agent/claude/deep/y")
64+ out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y")
65+ require.NoError(t, err, out)
66+ out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y")
67+ require.NoError(t, err, out)
68+ _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y")
69+ require.Error(t, err, "deleted")
70+
71+ // Every other ref refuses the push, with a message naming it.
72+ before := refs()
73+ for _, tt := range []struct{ name, refspec, ref string }{
74+ {"main", "HEAD:refs/heads/main", "refs/heads/main"},
75+ {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"},
76+ {"delete main", ":refs/heads/main", "refs/heads/main"},
77+ {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"},
78+ {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"},
79+ {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"},
80+ {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"},
81+ {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"},
82+ {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"},
83+ {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"},
84+ } {
85+ out, err = h.git(wt, "push", remote, tt.refspec)
86+ require.Error(t, err, tt.name)
87+ assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name)
88+ assert.Equal(t, before, refs(), "%s: nothing moved", tt.name)
89+ }
90+
91+ // A mixed push is refused as a whole: the allowed ref does not move either.
92+ commit("x3")
93+ out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main")
94+ require.Error(t, err)
95+ assert.Contains(t, out, "refs/heads/main is outside")
96+ assert.Equal(t, before, refs())
97+ out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2")
98+ require.Error(t, err)
99+ assert.Contains(t, out, "refs/tags/v2 is outside")
100+ assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both")
101+ assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
102+
103+ // It cannot create a repository: not by push-to-create, not by the API.
104+ h.mustGit(wt, "checkout", "-q", "agent/claude/x")
105+ out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x")
106+ require.Error(t, err, out)
107+ assert.NoDirExists(t, h.repoDir("brand-new"))
108+ anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository
109+ out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x")
110+ require.Error(t, err, out)
111+ assert.NoDirExists(t, h.repoDir("brand-new"))
112+ assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo))
113+ _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
114+ assert.Equal(t, "permission_denied", codeName(err))
115+ assert.NoDirExists(t, h.repoDir("via-api"))
116+ // The same token without prefixes does both.
117+ free := h.token(scopes(domain.ScopeRepoWrite))
118+ _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
119+ require.NoError(t, err)
120+
121+ // It cannot delete a repository, even with repo:admin.
122+ admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api")
123+ _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))
124+ assert.Equal(t, "permission_denied", codeName(err))
125+ assert.DirExists(t, h.repoDir("via-api"))
126+ // ... nor change its settings (default branch, visibility, ...).
127+ _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
128+ Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
129+ }))
130+ assert.Equal(t, "permission_denied", codeName(err))
131+ got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"}))
132+ require.NoError(t, err)
133+ assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility())
134+
135+ // It never has owner rights, whatever its scopes.
136+ all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})
137+ _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
138+ assert.Equal(t, "permission_denied", codeName(err))
139+ _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
140+ assert.Equal(t, "permission_denied", codeName(err))
141+}
142+
143+// AC5: merge requests write branches: a limited token merges only into its prefixes.
144+func TestAC5BranchLimitedMerge(t *testing.T) {
145+ h := newHarness(t, harnessOpts{})
146+ wt := h.workTree("main", "a.txt")
147+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
148+ require.NoError(t, err, out)
149+ h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat")
150+ h.writeFile(wt, "f.txt", []byte("f\n"))
151+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat")
152+ feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")
153+ mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
154+
155+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj")
156+ c := h.api(secret)
157+ created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
158+ Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat",
159+ }))
160+ require.NoError(t, err, "opening a request writes no ref")
161+ n := created.Msg.GetMergeRequest().GetNumber()
162+
163+ for _, del := range []bool{false, true} {
164+ _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
165+ Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del,
166+ }))
167+ assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del)
168+ }
169+ assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
170+ assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat"))
171+
172+ // The merge goes through for a token without prefixes.
173+ m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge)))
174+ _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat}))
175+ require.NoError(t, err)
176+ assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main"))
177+}
178+
179+// AC5: a limited token only creates tokens within its prefixes, over the API.
180+func TestAC5BranchLimitedTokenCreation(t *testing.T) {
181+ h := newHarness(t, harnessOpts{})
182+ secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"})
183+ c := h.api(secret)
184+ create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) {
185+ return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{
186+ Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes,
187+ }))
188+ }
189+ _, err := create()
190+ assert.Equal(t, "permission_denied", codeName(err), "no prefixes")
191+ _, err = create("agent/")
192+ assert.Equal(t, "permission_denied", codeName(err), "wider")
193+ _, err = create("agent/claudex/")
194+ assert.Equal(t, "permission_denied", codeName(err), "sibling")
195+ _, err = create("agent")
196+ assert.Equal(t, "invalid_argument", codeName(err), "malformed")
197+ res, err := create("agent/claude/sub/")
198+ require.NoError(t, err)
199+ assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes())
200+
201+ // The listing shows the prefixes.
202+ list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
203+ require.NoError(t, err)
204+ var seen int
205+ for _, tk := range list.Msg.GetTokens() {
206+ if tk.GetLabel() == "child" {
207+ assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes())
208+ seen++
209+ }
210+ }
211+ assert.Equal(t, 1, seen)
212+}
@@ -0,0 +1,212 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// branchToken creates a stored token limited to branch prefixes, as root.
21+func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) {
22+ h.t.Helper()
23+ p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes}
24+ for _, r := range repos {
25+ p.Repos = append(p.Repos, h.name(r))
26+ }
27+ got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
28+ require.NoError(h.t, err)
29+ return got.Secret, got.Token.ID
30+}
31+
32+// AC5: a token limited to branch prefixes, through real git pushes and the API.
33+func TestAC5BranchLimitedToken(t *testing.T) {
34+ h := newHarness(t, harnessOpts{})
35+ wt := h.workTree("main", "a.txt")
36+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
37+ require.NoError(t, err, out)
38+ mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
39+
40+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge),
41+ []string{"agent/claude/"}, "proj")
42+ remote := h.urlAuth(secret, "/proj.git")
43+ commit := func(file string) {
44+ require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
45+ h.mustGit(wt, "add", file)
46+ h.mustGit(wt, "commit", "-q", "-m", file)
47+ }
48+ refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
49+
50+ // It pushes, force-pushes and deletes under its prefix.
51+ h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x")
52+ commit("x1")
53+ out, err = h.git(wt, "push", "-q", remote, "agent/claude/x")
54+ require.NoError(t, err, out)
55+ assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
56+
57+ h.mustGit(wt, "reset", "-q", "--hard", "main")
58+ commit("x2")
59+ out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x")
60+ require.NoError(t, err, out)
61+ assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
62+
63+ h.mustGit(wt, "branch", "agent/claude/deep/y")
64+ out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y")
65+ require.NoError(t, err, out)
66+ out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y")
67+ require.NoError(t, err, out)
68+ _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y")
69+ require.Error(t, err, "deleted")
70+
71+ // Every other ref refuses the push, with a message naming it.
72+ before := refs()
73+ for _, tt := range []struct{ name, refspec, ref string }{
74+ {"main", "HEAD:refs/heads/main", "refs/heads/main"},
75+ {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"},
76+ {"delete main", ":refs/heads/main", "refs/heads/main"},
77+ {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"},
78+ {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"},
79+ {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"},
80+ {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"},
81+ {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"},
82+ {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"},
83+ {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"},
84+ } {
85+ out, err = h.git(wt, "push", remote, tt.refspec)
86+ require.Error(t, err, tt.name)
87+ assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name)
88+ assert.Equal(t, before, refs(), "%s: nothing moved", tt.name)
89+ }
90+
91+ // A mixed push is refused as a whole: the allowed ref does not move either.
92+ commit("x3")
93+ out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main")
94+ require.Error(t, err)
95+ assert.Contains(t, out, "refs/heads/main is outside")
96+ assert.Equal(t, before, refs())
97+ out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2")
98+ require.Error(t, err)
99+ assert.Contains(t, out, "refs/tags/v2 is outside")
100+ assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both")
101+ assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
102+
103+ // It cannot create a repository: not by push-to-create, not by the API.
104+ h.mustGit(wt, "checkout", "-q", "agent/claude/x")
105+ out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x")
106+ require.Error(t, err, out)
107+ assert.NoDirExists(t, h.repoDir("brand-new"))
108+ anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository
109+ out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x")
110+ require.Error(t, err, out)
111+ assert.NoDirExists(t, h.repoDir("brand-new"))
112+ assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo))
113+ _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
114+ assert.Equal(t, "permission_denied", codeName(err))
115+ assert.NoDirExists(t, h.repoDir("via-api"))
116+ // The same token without prefixes does both.
117+ free := h.token(scopes(domain.ScopeRepoWrite))
118+ _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
119+ require.NoError(t, err)
120+
121+ // It cannot delete a repository, even with repo:admin.
122+ admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api")
123+ _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))
124+ assert.Equal(t, "permission_denied", codeName(err))
125+ assert.DirExists(t, h.repoDir("via-api"))
126+ // ... nor change its settings (default branch, visibility, ...).
127+ _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
128+ Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
129+ }))
130+ assert.Equal(t, "permission_denied", codeName(err))
131+ got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"}))
132+ require.NoError(t, err)
133+ assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility())
134+
135+ // It never has owner rights, whatever its scopes.
136+ all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})
137+ _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
138+ assert.Equal(t, "permission_denied", codeName(err))
139+ _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
140+ assert.Equal(t, "permission_denied", codeName(err))
141+}
142+
143+// AC5: merge requests write branches: a limited token merges only into its prefixes.
144+func TestAC5BranchLimitedMerge(t *testing.T) {
145+ h := newHarness(t, harnessOpts{})
146+ wt := h.workTree("main", "a.txt")
147+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
148+ require.NoError(t, err, out)
149+ h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat")
150+ h.writeFile(wt, "f.txt", []byte("f\n"))
151+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat")
152+ feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")
153+ mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
154+
155+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj")
156+ c := h.api(secret)
157+ created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
158+ Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat",
159+ }))
160+ require.NoError(t, err, "opening a request writes no ref")
161+ n := created.Msg.GetMergeRequest().GetNumber()
162+
163+ for _, del := range []bool{false, true} {
164+ _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
165+ Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del,
166+ }))
167+ assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del)
168+ }
169+ assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
170+ assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat"))
171+
172+ // The merge goes through for a token without prefixes.
173+ m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge)))
174+ _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat}))
175+ require.NoError(t, err)
176+ assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main"))
177+}
178+
179+// AC5: a limited token only creates tokens within its prefixes, over the API.
180+func TestAC5BranchLimitedTokenCreation(t *testing.T) {
181+ h := newHarness(t, harnessOpts{})
182+ secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"})
183+ c := h.api(secret)
184+ create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) {
185+ return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{
186+ Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes,
187+ }))
188+ }
189+ _, err := create()
190+ assert.Equal(t, "permission_denied", codeName(err), "no prefixes")
191+ _, err = create("agent/")
192+ assert.Equal(t, "permission_denied", codeName(err), "wider")
193+ _, err = create("agent/claudex/")
194+ assert.Equal(t, "permission_denied", codeName(err), "sibling")
195+ _, err = create("agent")
196+ assert.Equal(t, "invalid_argument", codeName(err), "malformed")
197+ res, err := create("agent/claude/sub/")
198+ require.NoError(t, err)
199+ assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes())
200+
201+ // The listing shows the prefixes.
202+ list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
203+ require.NoError(t, err)
204+ var seen int
205+ for _, tk := range list.Msg.GetTokens() {
206+ if tk.GetLabel() == "child" {
207+ assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes())
208+ seen++
209+ }
210+ }
211+ assert.Equal(t, 1, seen)
212+}
M internal/domain/principal.go +67 -15
@@ -4,6 +4,7 @@ package domain
44
55 import (
66 "slices"
7+ "strings"
78 "time"
89 )
910
@@ -22,12 +23,14 @@ const (
2223 // token, the owner signed in on the web, or a stored token with its scopes
2324 // and repositories.
2425 type Principal struct {
25- kind principalKind
26- tokenID string
27- scopes []Scope
28- repos []RepoName
29- expiresAt time.Time
30- label string
26+ kind principalKind
27+ tokenID string
28+ scopes []Scope
29+ repos []RepoName
30+ // branchPrefixes limits the branches a token may write; empty = all.
31+ branchPrefixes []string
32+ expiresAt time.Time
33+ label string
3134 }
3235
3336 // Anonymous returns the principal of a caller without credentials.
@@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} }
4447 // TokenPrincipal returns the principal of the holder of a stored token.
4548 func TokenPrincipal(t Token) Principal {
4649 return Principal{
47- kind: kindToken,
48- tokenID: t.ID,
49- scopes: slices.Clone(t.Scopes),
50- repos: slices.Clone(t.Repos),
51- expiresAt: t.ExpiresAt,
52- label: t.Label,
50+ kind: kindToken,
51+ tokenID: t.ID,
52+ scopes: slices.Clone(t.Scopes),
53+ repos: slices.Clone(t.Repos),
54+ branchPrefixes: slices.Clone(t.BranchPrefixes),
55+ expiresAt: t.ExpiresAt,
56+ label: t.Label,
5357 }
5458 }
5559
@@ -72,13 +76,13 @@ func (p Principal) Label() string {
7276 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
7377
7478 // HasOwnerRights reports whether p is the owner: root, the owner's web
75-// session, or a stored token that holds every scope and is not limited to a
76-// list of repositories. It decides who may manage browser sessions.
79+// session, or a stored token that holds every scope and is limited neither to
80+// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
7781 func (p Principal) HasOwnerRights() bool {
7882 if p.IsRoot() {
7983 return true
8084 }
81- if p.kind != kindToken || len(p.repos) > 0 {
85+ if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() {
8286 return false
8387 }
8488 for _, s := range AllScopes() {
@@ -105,6 +109,54 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
105109 // repository.
106110 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
107111
112+// BranchPrefixes returns the branch prefixes of a stored token; empty means
113+// every branch.
114+func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
115+
116+// IsBranchLimited reports whether p is limited to branch prefixes. A limited
117+// principal has no owner rights, creates no repository and administers none.
118+func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 }
119+
120+// CoversBranchPrefix reports whether s lies under one of the branch prefixes of
121+// p: s starts with one of them. An empty prefix, which ParseBranchPrefix never
122+// lets in, covers nothing instead of everything, so that a corrupt record
123+// limits a token and never widens it. It is false without prefixes: callers
124+// test IsBranchLimited first.
125+func (p Principal) CoversBranchPrefix(s string) bool {
126+ for _, prefix := range p.branchPrefixes {
127+ if prefix != "" && strings.HasPrefix(s, prefix) {
128+ return true
129+ }
130+ }
131+ return false
132+}
133+
134+// AllowsRef reports whether p may write the ref (create, update or delete
135+// it). Without branch prefixes every ref is allowed. With them, only a branch
136+// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
137+// a branch outside the prefixes are not.
138+//
139+// It judges the name of the ref only, and rests on two assumptions about the
140+// repositories ermite serves. First, no symbolic ref lives under refs/heads
141+// (HEAD is the only symref): ermite never creates one and receive-pack cannot,
142+// so a name under a prefix never points at a branch outside it. Second, ref
143+// names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x"
144+// is not "refs/heads/agent/x". On a case-insensitive file system the two could
145+// name the same ref and the prefixes would not hold.
146+func (p Principal) AllowsRef(ref string) bool {
147+ if !p.IsBranchLimited() {
148+ return true
149+ }
150+ name, ok := strings.CutPrefix(ref, "refs/heads/")
151+ if !ok {
152+ return false
153+ }
154+ if _, err := ParseBranchName(name); err != nil {
155+ return false
156+ }
157+ return p.CoversBranchPrefix(name)
158+}
159+
108160 // ExpiresAt returns the expiry of a stored token; zero means none.
109161 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
110162
@@ -4,6 +4,7 @@ package domain
4 4
5 import (5 import (
6 "slices"6 "slices"
7+ "strings"
7 "time"8 "time"
8 )9 )
9 10
@@ -22,12 +23,14 @@ const (
22 // token, the owner signed in on the web, or a stored token with its scopes23 // token, the owner signed in on the web, or a stored token with its scopes
23 // and repositories.24 // and repositories.
24 type Principal struct {25 type Principal struct {
25- kind principalKind26+ kind principalKind
26- tokenID string27+ tokenID string
27- scopes []Scope28+ scopes []Scope
28- repos []RepoName29+ repos []RepoName
29- expiresAt time.Time30+ // branchPrefixes limits the branches a token may write; empty = all.
30- label string31+ branchPrefixes []string
32+ expiresAt time.Time
33+ label string
31 }34 }
32 35
33 // Anonymous returns the principal of a caller without credentials.36 // Anonymous returns the principal of a caller without credentials.
@@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} }
44 // TokenPrincipal returns the principal of the holder of a stored token.47 // TokenPrincipal returns the principal of the holder of a stored token.
45 func TokenPrincipal(t Token) Principal {48 func TokenPrincipal(t Token) Principal {
46 return Principal{49 return Principal{
47- kind: kindToken,50+ kind: kindToken,
48- tokenID: t.ID,51+ tokenID: t.ID,
49- scopes: slices.Clone(t.Scopes),52+ scopes: slices.Clone(t.Scopes),
50- repos: slices.Clone(t.Repos),53+ repos: slices.Clone(t.Repos),
51- expiresAt: t.ExpiresAt,54+ branchPrefixes: slices.Clone(t.BranchPrefixes),
52- label: t.Label,55+ expiresAt: t.ExpiresAt,
56+ label: t.Label,
53 }57 }
54 }58 }
55 59
@@ -72,13 +76,13 @@ func (p Principal) Label() string {
72 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }76 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
73 77
74 // HasOwnerRights reports whether p is the owner: root, the owner's web78 // HasOwnerRights reports whether p is the owner: root, the owner's web
75-// session, or a stored token that holds every scope and is not limited to a79+// session, or a stored token that holds every scope and is limited neither to
76-// list of repositories. It decides who may manage browser sessions.80+// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
77 func (p Principal) HasOwnerRights() bool {81 func (p Principal) HasOwnerRights() bool {
78 if p.IsRoot() {82 if p.IsRoot() {
79 return true83 return true
80 }84 }
81- if p.kind != kindToken || len(p.repos) > 0 {85+ if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() {
82 return false86 return false
83 }87 }
84 for _, s := range AllScopes() {88 for _, s := range AllScopes() {
@@ -105,6 +109,54 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
105 // repository.109 // repository.
106 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }110 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
107 111
112+// BranchPrefixes returns the branch prefixes of a stored token; empty means
113+// every branch.
114+func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
115+
116+// IsBranchLimited reports whether p is limited to branch prefixes. A limited
117+// principal has no owner rights, creates no repository and administers none.
118+func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 }
119+
120+// CoversBranchPrefix reports whether s lies under one of the branch prefixes of
121+// p: s starts with one of them. An empty prefix, which ParseBranchPrefix never
122+// lets in, covers nothing instead of everything, so that a corrupt record
123+// limits a token and never widens it. It is false without prefixes: callers
124+// test IsBranchLimited first.
125+func (p Principal) CoversBranchPrefix(s string) bool {
126+ for _, prefix := range p.branchPrefixes {
127+ if prefix != "" && strings.HasPrefix(s, prefix) {
128+ return true
129+ }
130+ }
131+ return false
132+}
133+
134+// AllowsRef reports whether p may write the ref (create, update or delete
135+// it). Without branch prefixes every ref is allowed. With them, only a branch
136+// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
137+// a branch outside the prefixes are not.
138+//
139+// It judges the name of the ref only, and rests on two assumptions about the
140+// repositories ermite serves. First, no symbolic ref lives under refs/heads
141+// (HEAD is the only symref): ermite never creates one and receive-pack cannot,
142+// so a name under a prefix never points at a branch outside it. Second, ref
143+// names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x"
144+// is not "refs/heads/agent/x". On a case-insensitive file system the two could
145+// name the same ref and the prefixes would not hold.
146+func (p Principal) AllowsRef(ref string) bool {
147+ if !p.IsBranchLimited() {
148+ return true
149+ }
150+ name, ok := strings.CutPrefix(ref, "refs/heads/")
151+ if !ok {
152+ return false
153+ }
154+ if _, err := ParseBranchName(name); err != nil {
155+ return false
156+ }
157+ return p.CoversBranchPrefix(name)
158+}
159+
108 // ExpiresAt returns the expiry of a stored token; zero means none.160 // ExpiresAt returns the expiry of a stored token; zero means none.
109 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }161 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
110 162
M internal/domain/token.go +49 -0
@@ -7,6 +7,7 @@ import (
77 "encoding/hex"
88 "fmt"
99 "slices"
10+ "strings"
1011 "time"
1112 )
1213
@@ -80,6 +81,10 @@ type Token struct {
8081 Scopes []Scope
8182 // Repos limits the token to these repositories; empty means every one.
8283 Repos []RepoName
84+ // BranchPrefixes limits the branches the token may write to those under
85+ // one of these prefixes (each ends with "/"); empty means every branch.
86+ // A limited token cannot write tags or other refs, nor create repositories.
87+ BranchPrefixes []string
8388 // ExpiresAt is the zero time for a token that never expires.
8489 ExpiresAt time.Time
8590 CreatedAt time.Time
@@ -90,3 +95,47 @@ type Token struct {
9095 func (t Token) Expired(now time.Time) bool {
9196 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
9297 }
98+
99+const (
100+ // MaxBranchPrefixLen is the longest branch prefix, in bytes.
101+ MaxBranchPrefixLen = 100
102+ // MaxBranchPrefixes is the most branch prefixes of a token.
103+ MaxBranchPrefixes = 8
104+)
105+
106+// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
107+// with "/", the rest is a valid branch name, and it has at most
108+// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
109+func ParseBranchPrefix(s string) (string, error) {
110+ if len(s) > MaxBranchPrefixLen {
111+ return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
112+ }
113+ name, ok := strings.CutSuffix(s, "/")
114+ if !ok {
115+ return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
116+ }
117+ if _, err := ParseBranchName(name); err != nil {
118+ return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
119+ }
120+ return s, nil
121+}
122+
123+// ParseBranchPrefixes validates a list of branch prefixes: at most
124+// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
125+// ErrInvalidToken. The result is nil for an empty list.
126+func ParseBranchPrefixes(in []string) ([]string, error) {
127+ var out []string
128+ for _, s := range in {
129+ p, err := ParseBranchPrefix(s)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if !slices.Contains(out, p) {
134+ out = append(out, p)
135+ }
136+ }
137+ if len(out) > MaxBranchPrefixes {
138+ return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
139+ }
140+ return out, nil
141+}
@@ -7,6 +7,7 @@ import (
7 "encoding/hex"7 "encoding/hex"
8 "fmt"8 "fmt"
9 "slices"9 "slices"
10+ "strings"
10 "time"11 "time"
11 )12 )
12 13
@@ -80,6 +81,10 @@ type Token struct {
80 Scopes []Scope81 Scopes []Scope
81 // Repos limits the token to these repositories; empty means every one.82 // Repos limits the token to these repositories; empty means every one.
82 Repos []RepoName83 Repos []RepoName
84+ // BranchPrefixes limits the branches the token may write to those under
85+ // one of these prefixes (each ends with "/"); empty means every branch.
86+ // A limited token cannot write tags or other refs, nor create repositories.
87+ BranchPrefixes []string
83 // ExpiresAt is the zero time for a token that never expires.88 // ExpiresAt is the zero time for a token that never expires.
84 ExpiresAt time.Time89 ExpiresAt time.Time
85 CreatedAt time.Time90 CreatedAt time.Time
@@ -90,3 +95,47 @@ type Token struct {
90 func (t Token) Expired(now time.Time) bool {95 func (t Token) Expired(now time.Time) bool {
91 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)96 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
92 }97 }
98+
99+const (
100+ // MaxBranchPrefixLen is the longest branch prefix, in bytes.
101+ MaxBranchPrefixLen = 100
102+ // MaxBranchPrefixes is the most branch prefixes of a token.
103+ MaxBranchPrefixes = 8
104+)
105+
106+// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
107+// with "/", the rest is a valid branch name, and it has at most
108+// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
109+func ParseBranchPrefix(s string) (string, error) {
110+ if len(s) > MaxBranchPrefixLen {
111+ return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
112+ }
113+ name, ok := strings.CutSuffix(s, "/")
114+ if !ok {
115+ return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
116+ }
117+ if _, err := ParseBranchName(name); err != nil {
118+ return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
119+ }
120+ return s, nil
121+}
122+
123+// ParseBranchPrefixes validates a list of branch prefixes: at most
124+// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
125+// ErrInvalidToken. The result is nil for an empty list.
126+func ParseBranchPrefixes(in []string) ([]string, error) {
127+ var out []string
128+ for _, s := range in {
129+ p, err := ParseBranchPrefix(s)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if !slices.Contains(out, p) {
134+ out = append(out, p)
135+ }
136+ }
137+ if len(out) > MaxBranchPrefixes {
138+ return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
139+ }
140+ return out, nil
141+}
M internal/domain/token_test.go +127 -0
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strings"
67 "testing"
78 "time"
89
@@ -111,3 +112,129 @@ func TestHasOwnerRights(t *testing.T) {
111112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
112113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
113114 }
115+
116+func TestParseBranchPrefix(t *testing.T) {
117+ t.Parallel()
118+ for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} {
119+ got, err := domain.ParseBranchPrefix(ok)
120+ require.NoError(t, err, ok)
121+ assert.Equal(t, ok, got)
122+ }
123+ for _, bad := range []string{
124+ "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/",
125+ "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/",
126+ "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/",
127+ } {
128+ _, err := domain.ParseBranchPrefix(bad)
129+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
130+ }
131+}
132+
133+func TestParseBranchPrefixes(t *testing.T) {
134+ t.Parallel()
135+ got, err := domain.ParseBranchPrefixes(nil)
136+ require.NoError(t, err)
137+ assert.Empty(t, got)
138+
139+ got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"})
140+ require.NoError(t, err)
141+ assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept")
142+
143+ eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"}
144+ got, err = domain.ParseBranchPrefixes(eight)
145+ require.NoError(t, err)
146+ assert.Len(t, got, 8)
147+ _, err = domain.ParseBranchPrefixes(append(eight, "i/"))
148+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
149+ got, err = domain.ParseBranchPrefixes(append(eight, "a/"))
150+ require.NoError(t, err, "duplicates do not count")
151+ assert.Len(t, got, 8)
152+
153+ _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"})
154+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
155+}
156+
157+func TestAllowsRef(t *testing.T) {
158+ t.Parallel()
159+ limited := domain.TokenPrincipal(domain.Token{
160+ Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"},
161+ })
162+ for ref, want := range map[string]bool{
163+ "refs/heads/agent/claude/x": true,
164+ "refs/heads/agent/claude/a/b": true,
165+ "refs/heads/bot/y": true,
166+ "refs/heads/agent/claude": false,
167+ "refs/heads/agent/claude/": false,
168+ "refs/heads/agent/claudex/y": false,
169+ "refs/heads/main": false,
170+ "refs/heads/agent/other/x": false,
171+ "refs/heads/other/agent/claude/x": false,
172+ "refs/tags/v1": false,
173+ "refs/tags/agent/claude/x": false,
174+ "refs/notes/commits": false,
175+ "refs/remotes/agent/claude/x": false,
176+ "refs/changes/agent/claude/x": false,
177+ "agent/claude/x": false,
178+ "heads/agent/claude/x": false,
179+ "HEAD": false,
180+ "": false,
181+ "refs/heads/agent/claude/../main": false,
182+ "refs/heads/agent/claude/x.lock": false,
183+ "refs/heads/agent/claude/a b": false,
184+ "refs/heads//agent/claude/x": false,
185+ } {
186+ assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref)
187+ }
188+
189+ // A corrupt empty prefix still limits the token and allows nothing.
190+ corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}})
191+ assert.False(t, corrupt.AllowsRef("refs/heads/main"))
192+
193+ // Without prefixes every ref is allowed.
194+ unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}})
195+ assert.True(t, unlimited.AllowsRef("refs/tags/v1"))
196+ assert.True(t, unlimited.AllowsRef("refs/heads/main"))
197+ assert.True(t, domain.Root().AllowsRef("refs/tags/v1"))
198+ assert.True(t, domain.Owner().AllowsRef("refs/heads/main"))
199+}
200+
201+func TestBranchPrefixesOfPrincipal(t *testing.T) {
202+ t.Parallel()
203+ tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}}
204+ p := domain.TokenPrincipal(tok)
205+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
206+ assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner")
207+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
208+
209+ // The principal owns copies.
210+ tok.BranchPrefixes[0] = "b/"
211+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
212+ p.BranchPrefixes()[0] = "c/"
213+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
214+ assert.Empty(t, domain.Root().BranchPrefixes())
215+}
216+
217+func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) {
218+ t.Parallel()
219+ free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()})
220+ assert.False(t, free.IsBranchLimited())
221+ assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first")
222+ assert.False(t, domain.Root().IsBranchLimited())
223+ assert.False(t, domain.Owner().IsBranchLimited())
224+ assert.False(t, domain.Anonymous().IsBranchLimited())
225+
226+ limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}})
227+ assert.True(t, limited.IsBranchLimited())
228+ for s, want := range map[string]bool{
229+ "agent/claude/x": true, "agent/claude/": true, "bot/y": true,
230+ "agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false,
231+ } {
232+ assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s)
233+ }
234+
235+ // A corrupt empty prefix still limits, and covers nothing.
236+ corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}})
237+ assert.True(t, corrupt.IsBranchLimited())
238+ assert.False(t, corrupt.CoversBranchPrefix("main"))
239+ assert.False(t, corrupt.CoversBranchPrefix(""))
240+}
@@ -3,6 +3,7 @@
3 package domain_test3 package domain_test
4 4
5 import (5 import (
6+ "strings"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -111,3 +112,129 @@ func TestHasOwnerRights(t *testing.T) {
111 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
113 }114 }
115+
116+func TestParseBranchPrefix(t *testing.T) {
117+ t.Parallel()
118+ for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} {
119+ got, err := domain.ParseBranchPrefix(ok)
120+ require.NoError(t, err, ok)
121+ assert.Equal(t, ok, got)
122+ }
123+ for _, bad := range []string{
124+ "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/",
125+ "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/",
126+ "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/",
127+ } {
128+ _, err := domain.ParseBranchPrefix(bad)
129+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
130+ }
131+}
132+
133+func TestParseBranchPrefixes(t *testing.T) {
134+ t.Parallel()
135+ got, err := domain.ParseBranchPrefixes(nil)
136+ require.NoError(t, err)
137+ assert.Empty(t, got)
138+
139+ got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"})
140+ require.NoError(t, err)
141+ assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept")
142+
143+ eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"}
144+ got, err = domain.ParseBranchPrefixes(eight)
145+ require.NoError(t, err)
146+ assert.Len(t, got, 8)
147+ _, err = domain.ParseBranchPrefixes(append(eight, "i/"))
148+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
149+ got, err = domain.ParseBranchPrefixes(append(eight, "a/"))
150+ require.NoError(t, err, "duplicates do not count")
151+ assert.Len(t, got, 8)
152+
153+ _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"})
154+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
155+}
156+
157+func TestAllowsRef(t *testing.T) {
158+ t.Parallel()
159+ limited := domain.TokenPrincipal(domain.Token{
160+ Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"},
161+ })
162+ for ref, want := range map[string]bool{
163+ "refs/heads/agent/claude/x": true,
164+ "refs/heads/agent/claude/a/b": true,
165+ "refs/heads/bot/y": true,
166+ "refs/heads/agent/claude": false,
167+ "refs/heads/agent/claude/": false,
168+ "refs/heads/agent/claudex/y": false,
169+ "refs/heads/main": false,
170+ "refs/heads/agent/other/x": false,
171+ "refs/heads/other/agent/claude/x": false,
172+ "refs/tags/v1": false,
173+ "refs/tags/agent/claude/x": false,
174+ "refs/notes/commits": false,
175+ "refs/remotes/agent/claude/x": false,
176+ "refs/changes/agent/claude/x": false,
177+ "agent/claude/x": false,
178+ "heads/agent/claude/x": false,
179+ "HEAD": false,
180+ "": false,
181+ "refs/heads/agent/claude/../main": false,
182+ "refs/heads/agent/claude/x.lock": false,
183+ "refs/heads/agent/claude/a b": false,
184+ "refs/heads//agent/claude/x": false,
185+ } {
186+ assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref)
187+ }
188+
189+ // A corrupt empty prefix still limits the token and allows nothing.
190+ corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}})
191+ assert.False(t, corrupt.AllowsRef("refs/heads/main"))
192+
193+ // Without prefixes every ref is allowed.
194+ unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}})
195+ assert.True(t, unlimited.AllowsRef("refs/tags/v1"))
196+ assert.True(t, unlimited.AllowsRef("refs/heads/main"))
197+ assert.True(t, domain.Root().AllowsRef("refs/tags/v1"))
198+ assert.True(t, domain.Owner().AllowsRef("refs/heads/main"))
199+}
200+
201+func TestBranchPrefixesOfPrincipal(t *testing.T) {
202+ t.Parallel()
203+ tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}}
204+ p := domain.TokenPrincipal(tok)
205+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
206+ assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner")
207+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
208+
209+ // The principal owns copies.
210+ tok.BranchPrefixes[0] = "b/"
211+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
212+ p.BranchPrefixes()[0] = "c/"
213+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
214+ assert.Empty(t, domain.Root().BranchPrefixes())
215+}
216+
217+func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) {
218+ t.Parallel()
219+ free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()})
220+ assert.False(t, free.IsBranchLimited())
221+ assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first")
222+ assert.False(t, domain.Root().IsBranchLimited())
223+ assert.False(t, domain.Owner().IsBranchLimited())
224+ assert.False(t, domain.Anonymous().IsBranchLimited())
225+
226+ limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}})
227+ assert.True(t, limited.IsBranchLimited())
228+ for s, want := range map[string]bool{
229+ "agent/claude/x": true, "agent/claude/": true, "bot/y": true,
230+ "agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false,
231+ } {
232+ assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s)
233+ }
234+
235+ // A corrupt empty prefix still limits, and covers nothing.
236+ corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}})
237+ assert.True(t, corrupt.IsBranchLimited())
238+ assert.False(t, corrupt.CoversBranchPrefix("main"))
239+ assert.False(t, corrupt.CoversBranchPrefix(""))
240+}
M internal/ports/dto.go +3 -0
@@ -61,6 +61,9 @@ type CreateTokenParams struct {
6161 Scopes []domain.Scope
6262 // Repos limits the token to these repositories; empty means every one.
6363 Repos []domain.RepoName
64+ // BranchPrefixes limits the token to the branches under these prefixes
65+ // (see domain.ParseBranchPrefix); empty means every branch.
66+ BranchPrefixes []string
6467 // ExpiresAt is the zero time for a token that never expires.
6568 ExpiresAt time.Time
6669 }
@@ -61,6 +61,9 @@ type CreateTokenParams struct {
61 Scopes []domain.Scope61 Scopes []domain.Scope
62 // Repos limits the token to these repositories; empty means every one.62 // Repos limits the token to these repositories; empty means every one.
63 Repos []domain.RepoName63 Repos []domain.RepoName
64+ // BranchPrefixes limits the token to the branches under these prefixes
65+ // (see domain.ParseBranchPrefix); empty means every branch.
66+ BranchPrefixes []string
64 // ExpiresAt is the zero time for a token that never expires.67 // ExpiresAt is the zero time for a token that never expires.
65 ExpiresAt time.Time68 ExpiresAt time.Time
66 }69 }
M internal/usecase/access.go +18 -6
@@ -26,12 +26,18 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
2626 return p.Allows(domain.ScopeRepoRead, name)
2727 }
2828
29-// adminAccess is the access rule for changing or deleting a repository. A
30-// caller that may not administer it learns nothing it could not already read:
31-// not found when it cannot see the repository, unauthenticated when it is
32-// anonymous, forbidden otherwise.
29+// adminAccess is the access rule for changing or deleting a repository:
30+// repo:admin on it, and no branch prefixes. A token limited to some branches
31+// may not delete the repository (that deletes every branch, those outside its
32+// prefixes included), nor change its default branch, visibility, description,
33+// merge modes or failing-checks rule: these decide what happens to branches the
34+// token may not write. A caller that may not administer it learns nothing it
35+// could not already read: not found when it cannot see the repository,
36+// unauthenticated when it is anonymous, forbidden otherwise.
3337 func adminAccess(p domain.Principal, repo domain.Repo) error {
3438 switch {
39+ case canAdmin(p, repo.Name) && p.IsBranchLimited():
40+ return fmt.Errorf("%w: a token limited to branch prefixes may not administer a repository", domain.ErrForbidden)
3541 case canAdmin(p, repo.Name):
3642 return nil
3743 case !canRead(p, repo):
@@ -42,12 +48,18 @@ func adminAccess(p domain.Principal, repo domain.Repo) error {
4248 return domain.ErrForbidden
4349 }
4450
45-// canWrite is the access rule for pushing to a repository, or creating it on
46-// first push: repo:write on it.
51+// canWrite is the access rule for pushing to a repository: repo:write on it.
4752 func canWrite(p domain.Principal, name domain.RepoName) bool {
4853 return p.Allows(domain.ScopeRepoWrite, name)
4954 }
5055
56+// canCreateRepo is the access rule for creating a repository, by the API or on
57+// first push: repo:write on the name, and no branch prefixes. A token limited
58+// to some branches may push to a repository that exists; it never makes one.
59+func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
60+ return canWrite(p, name) && !p.IsBranchLimited()
61+}
62+
5163 // canManageTokens is the access rule for creating, listing and revoking
5264 // stored tokens: token:admin, which root holds.
5365 func canManageTokens(p domain.Principal) bool {
@@ -26,12 +26,18 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
26 return p.Allows(domain.ScopeRepoRead, name)26 return p.Allows(domain.ScopeRepoRead, name)
27 }27 }
28 28
29-// adminAccess is the access rule for changing or deleting a repository. A29+// adminAccess is the access rule for changing or deleting a repository:
30-// caller that may not administer it learns nothing it could not already read:30+// repo:admin on it, and no branch prefixes. A token limited to some branches
31-// not found when it cannot see the repository, unauthenticated when it is31+// may not delete the repository (that deletes every branch, those outside its
32-// anonymous, forbidden otherwise.32+// prefixes included), nor change its default branch, visibility, description,
33+// merge modes or failing-checks rule: these decide what happens to branches the
34+// token may not write. A caller that may not administer it learns nothing it
35+// could not already read: not found when it cannot see the repository,
36+// unauthenticated when it is anonymous, forbidden otherwise.
33 func adminAccess(p domain.Principal, repo domain.Repo) error {37 func adminAccess(p domain.Principal, repo domain.Repo) error {
34 switch {38 switch {
39+ case canAdmin(p, repo.Name) && p.IsBranchLimited():
40+ return fmt.Errorf("%w: a token limited to branch prefixes may not administer a repository", domain.ErrForbidden)
35 case canAdmin(p, repo.Name):41 case canAdmin(p, repo.Name):
36 return nil42 return nil
37 case !canRead(p, repo):43 case !canRead(p, repo):
@@ -42,12 +48,18 @@ func adminAccess(p domain.Principal, repo domain.Repo) error {
42 return domain.ErrForbidden48 return domain.ErrForbidden
43 }49 }
44 50
45-// canWrite is the access rule for pushing to a repository, or creating it on51+// canWrite is the access rule for pushing to a repository: repo:write on it.
46-// first push: repo:write on it.
47 func canWrite(p domain.Principal, name domain.RepoName) bool {52 func canWrite(p domain.Principal, name domain.RepoName) bool {
48 return p.Allows(domain.ScopeRepoWrite, name)53 return p.Allows(domain.ScopeRepoWrite, name)
49 }54 }
50 55
56+// canCreateRepo is the access rule for creating a repository, by the API or on
57+// first push: repo:write on the name, and no branch prefixes. A token limited
58+// to some branches may push to a repository that exists; it never makes one.
59+func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
60+ return canWrite(p, name) && !p.IsBranchLimited()
61+}
62+
51 // canManageTokens is the access rule for creating, listing and revoking63 // canManageTokens is the access rule for creating, listing and revoking
52 // stored tokens: token:admin, which root holds.64 // stored tokens: token:admin, which root holds.
53 func canManageTokens(p domain.Principal) bool {65 func canManageTokens(p domain.Principal) bool {
M internal/usecase/ci.go +26 -1
@@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R
462462 return repo, nil
463463 }
464464
465+// runWriteAccess is the branch rule of the write actions on a run, cancel and
466+// re-run, which come after the ci:write check. A run is tied to the ref that
467+// made it, and re-running it executes the workflow of that ref again while
468+// cancelling it stops the checks of that ref. So, like a push, a merge or a
469+// branch delete, these actions fall under the branch prefixes of a limited
470+// token (ADR 0027): it may act only on the runs of branches under its prefixes,
471+// and never on a run of a tag or of any other ref. Without prefixes, every run.
472+//
473+// Reading runs, jobs, logs and checks is not a write: it needs only the access
474+// to read the repository, as for the code, whatever the prefixes. The caller
475+// has seen the run by then, so the refusal is forbidden, not not found.
476+func runWriteAccess(p domain.Principal, run domain.CIRun) error {
477+ if !p.AllowsRef(run.Ref) {
478+ return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref)
479+ }
480+ return nil
481+}
482+
465483 // canManageRunners is the access rule of runners: runner:register on a token
466484 // that is not restricted to repositories (a runner serves every repository),
467-// or root.
485+// or root. A token limited to branch prefixes never manages runners, whatever
486+// scopes it holds: a runner runs the workflows of every branch of every
487+// repository, with their code and secrets, so registering or removing one
488+// reaches far beyond the prefixes (a replaced runner could also be a way to
489+// take over the jobs of main). Like repository administration and owner
490+// rights, it is out of reach of a limited token.
468491 func canManageRunners(p domain.Principal) error {
469492 switch {
470493 case p.IsAnonymous():
@@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error {
473496 return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)
474497 case len(p.Repos()) > 0:
475498 return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)
499+ case p.IsBranchLimited():
500+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden)
476501 }
477502 return nil
478503 }
@@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R
462 return repo, nil462 return repo, nil
463 }463 }
464 464
465+// runWriteAccess is the branch rule of the write actions on a run, cancel and
466+// re-run, which come after the ci:write check. A run is tied to the ref that
467+// made it, and re-running it executes the workflow of that ref again while
468+// cancelling it stops the checks of that ref. So, like a push, a merge or a
469+// branch delete, these actions fall under the branch prefixes of a limited
470+// token (ADR 0027): it may act only on the runs of branches under its prefixes,
471+// and never on a run of a tag or of any other ref. Without prefixes, every run.
472+//
473+// Reading runs, jobs, logs and checks is not a write: it needs only the access
474+// to read the repository, as for the code, whatever the prefixes. The caller
475+// has seen the run by then, so the refusal is forbidden, not not found.
476+func runWriteAccess(p domain.Principal, run domain.CIRun) error {
477+ if !p.AllowsRef(run.Ref) {
478+ return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref)
479+ }
480+ return nil
481+}
482+
465 // canManageRunners is the access rule of runners: runner:register on a token483 // canManageRunners is the access rule of runners: runner:register on a token
466 // that is not restricted to repositories (a runner serves every repository),484 // that is not restricted to repositories (a runner serves every repository),
467-// or root.485+// or root. A token limited to branch prefixes never manages runners, whatever
486+// scopes it holds: a runner runs the workflows of every branch of every
487+// repository, with their code and secrets, so registering or removing one
488+// reaches far beyond the prefixes (a replaced runner could also be a way to
489+// take over the jobs of main). Like repository administration and owner
490+// rights, it is out of reach of a limited token.
468 func canManageRunners(p domain.Principal) error {491 func canManageRunners(p domain.Principal) error {
469 switch {492 switch {
470 case p.IsAnonymous():493 case p.IsAnonymous():
@@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error {
473 return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)496 return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)
474 case len(p.Repos()) > 0:497 case len(p.Repos()) > 0:
475 return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)498 return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)
499+ case p.IsBranchLimited():
500+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden)
476 }501 }
477 return nil502 return nil
478 }503 }
M internal/usecase/ci_runs.go +12 -4
@@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
318318 if _, err := c.writableRepo(ctx, p, name); err != nil {
319319 return domain.CIRun{}, err
320320 }
321- if _, err := c.runOf(ctx, name, runID); err != nil {
321+ run, err := c.runOf(ctx, name, runID)
322+ if err != nil {
323+ return domain.CIRun{}, err
324+ }
325+ if err := runWriteAccess(p, run); err != nil {
322326 return domain.CIRun{}, err
323327 }
324328 var out domain.CIRun
325- err := c.withRun(ctx, runID, 0, func(rs *runState) error {
329+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
326330 if rs.run.Status == domain.CICompleted {
327331 return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)
328332 }
@@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
351355 if err != nil {
352356 return domain.CIRun{}, err
353357 }
354- if _, err := c.runOf(ctx, name, runID); err != nil {
358+ run, err := c.runOf(ctx, name, runID)
359+ if err != nil {
360+ return domain.CIRun{}, err
361+ }
362+ if err := runWriteAccess(p, run); err != nil {
355363 return domain.CIRun{}, err
356364 }
357365 err = c.withRun(ctx, runID, 0, func(rs *runState) error {
@@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
416424 if err := c.advanceRun(ctx, runID); err != nil {
417425 return domain.CIRun{}, err
418426 }
419- run, err := c.runOf(ctx, name, runID)
427+ run, err = c.runOf(ctx, name, runID)
420428 if err != nil {
421429 return domain.CIRun{}, err
422430 }
@@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo
318 if _, err := c.writableRepo(ctx, p, name); err != nil {318 if _, err := c.writableRepo(ctx, p, name); err != nil {
319 return domain.CIRun{}, err319 return domain.CIRun{}, err
320 }320 }
321- if _, err := c.runOf(ctx, name, runID); err != nil {321+ run, err := c.runOf(ctx, name, runID)
322+ if err != nil {
323+ return domain.CIRun{}, err
324+ }
325+ if err := runWriteAccess(p, run); err != nil {
322 return domain.CIRun{}, err326 return domain.CIRun{}, err
323 }327 }
324 var out domain.CIRun328 var out domain.CIRun
325- err := c.withRun(ctx, runID, 0, func(rs *runState) error {329+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
326 if rs.run.Status == domain.CICompleted {330 if rs.run.Status == domain.CICompleted {
327 return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)331 return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)
328 }332 }
@@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
351 if err != nil {355 if err != nil {
352 return domain.CIRun{}, err356 return domain.CIRun{}, err
353 }357 }
354- if _, err := c.runOf(ctx, name, runID); err != nil {358+ run, err := c.runOf(ctx, name, runID)
359+ if err != nil {
360+ return domain.CIRun{}, err
361+ }
362+ if err := runWriteAccess(p, run); err != nil {
355 return domain.CIRun{}, err363 return domain.CIRun{}, err
356 }364 }
357 err = c.withRun(ctx, runID, 0, func(rs *runState) error {365 err = c.withRun(ctx, runID, 0, func(rs *runState) error {
@@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN
416 if err := c.advanceRun(ctx, runID); err != nil {424 if err := c.advanceRun(ctx, runID); err != nil {
417 return domain.CIRun{}, err425 return domain.CIRun{}, err
418 }426 }
419- run, err := c.runOf(ctx, name, runID)427+ run, err = c.runOf(ctx, name, runID)
420 if err != nil {428 if err != nil {
421 return domain.CIRun{}, err429 return domain.CIRun{}, err
422 }430 }
M internal/usecase/mergerequests.go +8 -0
@@ -718,6 +718,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
718718 if err != nil {
719719 return domain.MergeRequest{}, false, err
720720 }
721+ // A merge moves the target and may delete the source: a token limited to
722+ // branch prefixes may do so only within them.
723+ if !p.AllowsRef("refs/heads/" + mr.Target) {
724+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
725+ }
726+ if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
727+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
728+ }
721729 if !mr.IsOpen() {
722730 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
723731 }
@@ -718,6 +718,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
718 if err != nil {718 if err != nil {
719 return domain.MergeRequest{}, false, err719 return domain.MergeRequest{}, false, err
720 }720 }
721+ // A merge moves the target and may delete the source: a token limited to
722+ // branch prefixes may do so only within them.
723+ if !p.AllowsRef("refs/heads/" + mr.Target) {
724+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
725+ }
726+ if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
727+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
728+ }
721 if !mr.IsOpen() {729 if !mr.IsOpen() {
722 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)730 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
723 }731 }
A internal/usecase/mergerequests_branches_test.go +113 -0
@@ -0,0 +1,113 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// branchMerger holds mr:merge but may only write the branches under
16+// agent/claude/.
17+var branchMerger = domain.TokenPrincipal(domain.Token{
18+ ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20+})
21+
22+// AC5: a merge writes the target, and the source when it is deleted; a token
23+// limited to branch prefixes may do neither outside them.
24+func TestMergeWithBranchLimitedToken(t *testing.T) {
25+ t.Parallel()
26+
27+ t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28+ t.Parallel()
29+ r := newMRRig(t)
30+ main := r.seed()
31+ head := r.feature("agent/claude/feat")
32+ mr := r.open("agent/claude/feat") // targets main
33+ r.drain()
34+
35+ for _, del := range []bool{false, true} {
36+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37+ require.ErrorIs(t, err, domain.ErrForbidden)
38+ assert.Contains(t, err.Error(), "this token may not write main")
39+ }
40+ assert.Equal(t, main, r.branchHead("main"), "main did not move")
41+ assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45+ assert.Empty(t, r.drain(), "no event")
46+
47+ // The same merge by an unlimited token works.
48+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49+ require.NoError(t, err)
50+ assert.Equal(t, head, r.branchHead("main"))
51+ })
52+
53+ t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54+ t.Parallel()
55+ r := newMRRig(t)
56+ r.seed()
57+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58+ r.push("agent/claude/base")
59+ r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60+ head := r.commit(author, "f.txt", "f\n", "add f")
61+ r.push("agent/claude/feat")
62+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63+ Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64+ })
65+ require.NoError(t, err)
66+
67+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68+ require.NoError(t, err)
69+ assert.True(t, res.SourceDeleted)
70+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
71+ _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72+ assert.ErrorIs(t, err, domain.ErrNotFound)
73+ })
74+
75+ t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76+ t.Parallel()
77+ r := newMRRig(t)
78+ r.seed()
79+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80+ r.push("agent/claude/base")
81+ base := r.branchHead("agent/claude/base")
82+ r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83+ head := r.commit(author, "o.txt", "o\n", "add o")
84+ r.push("other")
85+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86+ Title: "other", Source: "other", Target: "agent/claude/base",
87+ })
88+ require.NoError(t, err)
89+
90+ _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91+ require.ErrorIs(t, err, domain.ErrForbidden)
92+ assert.Contains(t, err.Error(), "this token may not write other")
93+ assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
95+
96+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97+ require.NoError(t, err)
98+ assert.False(t, res.SourceDeleted)
99+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
100+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
101+ })
102+
103+ t.Run("a squash into main is forbidden too", func(t *testing.T) {
104+ t.Parallel()
105+ r := newMRRig(t)
106+ main := r.seed()
107+ head := r.feature("agent/claude/feat")
108+ mr := r.open("agent/claude/feat")
109+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110+ require.ErrorIs(t, err, domain.ErrForbidden)
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
@@ -0,0 +1,113 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// branchMerger holds mr:merge but may only write the branches under
16+// agent/claude/.
17+var branchMerger = domain.TokenPrincipal(domain.Token{
18+ ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20+})
21+
22+// AC5: a merge writes the target, and the source when it is deleted; a token
23+// limited to branch prefixes may do neither outside them.
24+func TestMergeWithBranchLimitedToken(t *testing.T) {
25+ t.Parallel()
26+
27+ t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28+ t.Parallel()
29+ r := newMRRig(t)
30+ main := r.seed()
31+ head := r.feature("agent/claude/feat")
32+ mr := r.open("agent/claude/feat") // targets main
33+ r.drain()
34+
35+ for _, del := range []bool{false, true} {
36+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37+ require.ErrorIs(t, err, domain.ErrForbidden)
38+ assert.Contains(t, err.Error(), "this token may not write main")
39+ }
40+ assert.Equal(t, main, r.branchHead("main"), "main did not move")
41+ assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45+ assert.Empty(t, r.drain(), "no event")
46+
47+ // The same merge by an unlimited token works.
48+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49+ require.NoError(t, err)
50+ assert.Equal(t, head, r.branchHead("main"))
51+ })
52+
53+ t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54+ t.Parallel()
55+ r := newMRRig(t)
56+ r.seed()
57+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58+ r.push("agent/claude/base")
59+ r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60+ head := r.commit(author, "f.txt", "f\n", "add f")
61+ r.push("agent/claude/feat")
62+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63+ Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64+ })
65+ require.NoError(t, err)
66+
67+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68+ require.NoError(t, err)
69+ assert.True(t, res.SourceDeleted)
70+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
71+ _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72+ assert.ErrorIs(t, err, domain.ErrNotFound)
73+ })
74+
75+ t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76+ t.Parallel()
77+ r := newMRRig(t)
78+ r.seed()
79+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80+ r.push("agent/claude/base")
81+ base := r.branchHead("agent/claude/base")
82+ r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83+ head := r.commit(author, "o.txt", "o\n", "add o")
84+ r.push("other")
85+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86+ Title: "other", Source: "other", Target: "agent/claude/base",
87+ })
88+ require.NoError(t, err)
89+
90+ _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91+ require.ErrorIs(t, err, domain.ErrForbidden)
92+ assert.Contains(t, err.Error(), "this token may not write other")
93+ assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
95+
96+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97+ require.NoError(t, err)
98+ assert.False(t, res.SourceDeleted)
99+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
100+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
101+ })
102+
103+ t.Run("a squash into main is forbidden too", func(t *testing.T) {
104+ t.Parallel()
105+ r := newMRRig(t)
106+ main := r.seed()
107+ head := r.feature("agent/claude/feat")
108+ mr := r.open("agent/claude/feat")
109+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110+ require.ErrorIs(t, err, domain.ErrForbidden)
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
M internal/usecase/pushes.go +18 -4
@@ -77,7 +77,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
7777 return fmt.Errorf("get repository %q: %w", name, err)
7878 }
7979
80- if !create || !canWrite(p, name) {
80+ if !create || !canCreateRepo(p, name) {
8181 return domain.ErrNotFound
8282 }
8383 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
@@ -105,9 +105,17 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
105105 return domain.ErrNotFound
106106 }
107107
108-// Check is the pre-receive decision. v1 accepts every push of a principal that
109-// may write the repository; protected branches and limits will refuse here by
110-// returning an error that matches domain.ErrRejected.
108+// Check is the pre-receive decision. It accepts a push of a principal that
109+// may write the repository. A token limited to branch prefixes may only update
110+// (create, move or delete) branches under them: the first ref outside them
111+// refuses the whole push, tags and every other ref included. Further rules
112+// (protected branches, limits) will refuse here too, by returning an error
113+// that matches domain.ErrRejected.
114+//
115+// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
116+// symbolic ref under refs/heads besides HEAD (ermite never creates one and
117+// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
118+// every ref of the push, so a tag or any other ref is judged like a branch.
111119 //
112120 // The token of the push was valid when the request began, but a push can run
113121 // for long: it is re-validated here, so a token revoked or expired meanwhile
@@ -122,6 +130,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
122130 if !canWrite(p, name) {
123131 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
124132 }
133+ for _, up := range updates {
134+ if !p.AllowsRef(up.Ref) {
135+ return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
136+ domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
137+ }
138+ }
125139 log := zerowrap.FromCtx(ctx)
126140 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
127141 return nil
@@ -77,7 +77,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
77 return fmt.Errorf("get repository %q: %w", name, err)77 return fmt.Errorf("get repository %q: %w", name, err)
78 }78 }
79 79
80- if !create || !canWrite(p, name) {80+ if !create || !canCreateRepo(p, name) {
81 return domain.ErrNotFound81 return domain.ErrNotFound
82 }82 }
83 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})83 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
@@ -105,9 +105,17 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
105 return domain.ErrNotFound105 return domain.ErrNotFound
106 }106 }
107 107
108-// Check is the pre-receive decision. v1 accepts every push of a principal that108+// Check is the pre-receive decision. It accepts a push of a principal that
109-// may write the repository; protected branches and limits will refuse here by109+// may write the repository. A token limited to branch prefixes may only update
110-// returning an error that matches domain.ErrRejected.110+// (create, move or delete) branches under them: the first ref outside them
111+// refuses the whole push, tags and every other ref included. Further rules
112+// (protected branches, limits) will refuse here too, by returning an error
113+// that matches domain.ErrRejected.
114+//
115+// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
116+// symbolic ref under refs/heads besides HEAD (ermite never creates one and
117+// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
118+// every ref of the push, so a tag or any other ref is judged like a branch.
111 //119 //
112 // The token of the push was valid when the request began, but a push can run120 // The token of the push was valid when the request began, but a push can run
113 // for long: it is re-validated here, so a token revoked or expired meanwhile121 // for long: it is re-validated here, so a token revoked or expired meanwhile
@@ -122,6 +130,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
122 if !canWrite(p, name) {130 if !canWrite(p, name) {
123 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)131 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
124 }132 }
133+ for _, up := range updates {
134+ if !p.AllowsRef(up.Ref) {
135+ return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
136+ domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
137+ }
138+ }
125 log := zerowrap.FromCtx(ctx)139 log := zerowrap.FromCtx(ctx)
126 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")140 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
127 return nil141 return nil
M internal/usecase/pushes_test.go +83 -0
@@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) {
7676 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
7777 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
7878 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79+ {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80+ {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81+ {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
7982 }
8083 for _, tt := range tests {
8184 t.Run(tt.name, func(t *testing.T) {
@@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) {
203206 })
204207 }
205208
209+// AC5: a token limited to branch prefixes pushes only under them.
210+func TestPushesCheckBranchPrefixes(t *testing.T) {
211+ t.Parallel()
212+ ctx := context.Background()
213+ n := name(t, "tools")
214+ limited := domain.TokenPrincipal(domain.Token{
215+ ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216+ })
217+ up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218+
219+ tests := []struct {
220+ name string
221+ updates []domain.RefUpdate
222+ refused string // the ref named by the error; "" = accepted
223+ }{
224+ {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225+ {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226+ {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227+ {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228+ {"several under the prefixes", []domain.RefUpdate{
229+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230+ }, ""},
231+ {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232+ {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233+ {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234+ {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235+ {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236+ {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237+ {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238+ {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239+ {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240+ {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242+ }, "refs/heads/main"},
243+ {"mixed push, forbidden first", []domain.RefUpdate{
244+ up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245+ }, "refs/heads/main"},
246+ }
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ t.Parallel()
250+ f := newPushFixture(t)
251+ f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252+ err := f.pushes.Check(ctx, limited, n, tt.updates)
253+ if tt.refused == "" {
254+ require.NoError(t, err)
255+ return
256+ }
257+ require.ErrorIs(t, err, domain.ErrRejected)
258+ assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259+ })
260+ }
261+
262+ t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263+ t.Parallel()
264+ f := newPushFixture(t)
265+ f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266+ free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267+ require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268+ up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269+ }))
270+ })
271+
272+ t.Run("root pushes anywhere", func(t *testing.T) {
273+ t.Parallel()
274+ f := newPushFixture(t)
275+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276+ })
277+
278+ t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279+ t.Parallel()
280+ f := newPushFixture(t)
281+ f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282+ p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283+ err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284+ require.ErrorIs(t, err, domain.ErrRejected)
285+ assert.Contains(t, err.Error(), "no write access")
286+ })
287+}
288+
206289 func TestPushesRecord(t *testing.T) {
207290 t.Parallel()
208291 ctx := context.Background()
@@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) {
76 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},76 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
77 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},77 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
78 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},78 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79+ {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80+ {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81+ {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
79 }82 }
80 for _, tt := range tests {83 for _, tt := range tests {
81 t.Run(tt.name, func(t *testing.T) {84 t.Run(tt.name, func(t *testing.T) {
@@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) {
203 })206 })
204 }207 }
205 208
209+// AC5: a token limited to branch prefixes pushes only under them.
210+func TestPushesCheckBranchPrefixes(t *testing.T) {
211+ t.Parallel()
212+ ctx := context.Background()
213+ n := name(t, "tools")
214+ limited := domain.TokenPrincipal(domain.Token{
215+ ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216+ })
217+ up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218+
219+ tests := []struct {
220+ name string
221+ updates []domain.RefUpdate
222+ refused string // the ref named by the error; "" = accepted
223+ }{
224+ {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225+ {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226+ {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227+ {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228+ {"several under the prefixes", []domain.RefUpdate{
229+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230+ }, ""},
231+ {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232+ {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233+ {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234+ {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235+ {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236+ {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237+ {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238+ {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239+ {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240+ {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242+ }, "refs/heads/main"},
243+ {"mixed push, forbidden first", []domain.RefUpdate{
244+ up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245+ }, "refs/heads/main"},
246+ }
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ t.Parallel()
250+ f := newPushFixture(t)
251+ f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252+ err := f.pushes.Check(ctx, limited, n, tt.updates)
253+ if tt.refused == "" {
254+ require.NoError(t, err)
255+ return
256+ }
257+ require.ErrorIs(t, err, domain.ErrRejected)
258+ assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259+ })
260+ }
261+
262+ t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263+ t.Parallel()
264+ f := newPushFixture(t)
265+ f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266+ free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267+ require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268+ up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269+ }))
270+ })
271+
272+ t.Run("root pushes anywhere", func(t *testing.T) {
273+ t.Parallel()
274+ f := newPushFixture(t)
275+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276+ })
277+
278+ t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279+ t.Parallel()
280+ f := newPushFixture(t)
281+ f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282+ p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283+ err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284+ require.ErrorIs(t, err, domain.ErrRejected)
285+ assert.Contains(t, err.Error(), "no write access")
286+ })
287+}
288+
206 func TestPushesRecord(t *testing.T) {289 func TestPushesRecord(t *testing.T) {
207 t.Parallel()290 t.Parallel()
208 ctx := context.Background()291 ctx := context.Background()
M internal/usecase/repos.go +4 -3
@@ -45,8 +45,9 @@ func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
4545 // after the metadata is written removes it again and discards the staged
4646 // repository.
4747 //
48-// The principal needs repo:write on the name: anonymous callers get
49-// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.
48+// The principal needs repo:write on the name and no branch prefixes: anonymous
49+// callers get domain.ErrUnauthenticated, other callers without it (a token
50+// limited to some branches included) domain.ErrForbidden.
5051 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
5152 if params.Name.IsZero() {
5253 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -54,7 +55,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
5455 if p.IsAnonymous() {
5556 return domain.Repo{}, domain.ErrUnauthenticated
5657 }
57- if !canWrite(p, params.Name) {
58+ if !canCreateRepo(p, params.Name) {
5859 return domain.Repo{}, domain.ErrForbidden
5960 }
6061 if err := validateDescription(params.Description); err != nil {
@@ -45,8 +45,9 @@ func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
45 // after the metadata is written removes it again and discards the staged45 // after the metadata is written removes it again and discards the staged
46 // repository.46 // repository.
47 //47 //
48-// The principal needs repo:write on the name: anonymous callers get48+// The principal needs repo:write on the name and no branch prefixes: anonymous
49-// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.49+// callers get domain.ErrUnauthenticated, other callers without it (a token
50+// limited to some branches included) domain.ErrForbidden.
50 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {51 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
51 if params.Name.IsZero() {52 if params.Name.IsZero() {
52 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)53 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -54,7 +55,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
54 if p.IsAnonymous() {55 if p.IsAnonymous() {
55 return domain.Repo{}, domain.ErrUnauthenticated56 return domain.Repo{}, domain.ErrUnauthenticated
56 }57 }
57- if !canWrite(p, params.Name) {58+ if !canCreateRepo(p, params.Name) {
58 return domain.Repo{}, domain.ErrForbidden59 return domain.Repo{}, domain.ErrForbidden
59 }60 }
60 if err := validateDescription(params.Description); err != nil {61 if err := validateDescription(params.Description); err != nil {
M internal/usecase/repos_test.go +26 -2
@@ -86,6 +86,18 @@ func TestCreate(t *testing.T) {
8686 }
8787 })
8888
89+ t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
90+ t.Parallel()
91+ f := newFixture(t) // the mocks expect no call: nothing is staged
92+ for _, p := range []domain.Principal{
93+ branchLimited(domain.ScopeRepoWrite),
94+ branchLimited(domain.ScopeRepoWrite, "tools"),
95+ } {
96+ _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
97+ assert.ErrorIs(t, err, domain.ErrForbidden)
98+ }
99+ })
100+
89101 t.Run("token with repo:write creates", func(t *testing.T) {
90102 t.Parallel()
91103 f := newFixture(t)
@@ -179,8 +191,10 @@ func TestCreate(t *testing.T) {
179191 })
180192 }
181193
182-func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
183- tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}
194+// scopedToken is the principal of a token with one scope, limited to the
195+// repositories and to the branch prefixes given (none means no limit).
196+func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal {
197+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes}
184198 for _, r := range repos {
185199 n, err := domain.ParseRepoName(r)
186200 if err != nil {
@@ -191,6 +205,16 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
191205 return domain.TokenPrincipal(tok)
192206 }
193207
208+func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
209+ return scopedToken(s, nil, repos...)
210+}
211+
212+// branchLimited is a token with one scope, optionally limited to repositories,
213+// and limited to the branches under "agent/".
214+func branchLimited(s domain.Scope, repos ...string) domain.Principal {
215+ return scopedToken(s, []string{"agent/"}, repos...)
216+}
217+
194218 func TestGetAndListAccess(t *testing.T) {
195219 t.Parallel()
196220 ctx := context.Background()
@@ -86,6 +86,18 @@ func TestCreate(t *testing.T) {
86 }86 }
87 })87 })
88 88
89+ t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
90+ t.Parallel()
91+ f := newFixture(t) // the mocks expect no call: nothing is staged
92+ for _, p := range []domain.Principal{
93+ branchLimited(domain.ScopeRepoWrite),
94+ branchLimited(domain.ScopeRepoWrite, "tools"),
95+ } {
96+ _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
97+ assert.ErrorIs(t, err, domain.ErrForbidden)
98+ }
99+ })
100+
89 t.Run("token with repo:write creates", func(t *testing.T) {101 t.Run("token with repo:write creates", func(t *testing.T) {
90 t.Parallel()102 t.Parallel()
91 f := newFixture(t)103 f := newFixture(t)
@@ -179,8 +191,10 @@ func TestCreate(t *testing.T) {
179 })191 })
180 }192 }
181 193
182-func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {194+// scopedToken is the principal of a token with one scope, limited to the
183- tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}195+// repositories and to the branch prefixes given (none means no limit).
196+func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal {
197+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes}
184 for _, r := range repos {198 for _, r := range repos {
185 n, err := domain.ParseRepoName(r)199 n, err := domain.ParseRepoName(r)
186 if err != nil {200 if err != nil {
@@ -191,6 +205,16 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
191 return domain.TokenPrincipal(tok)205 return domain.TokenPrincipal(tok)
192 }206 }
193 207
208+func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
209+ return scopedToken(s, nil, repos...)
210+}
211+
212+// branchLimited is a token with one scope, optionally limited to repositories,
213+// and limited to the branches under "agent/".
214+func branchLimited(s domain.Scope, repos ...string) domain.Principal {
215+ return scopedToken(s, []string{"agent/"}, repos...)
216+}
217+
194 func TestGetAndListAccess(t *testing.T) {218 func TestGetAndListAccess(t *testing.T) {
195 t.Parallel()219 t.Parallel()
196 ctx := context.Background()220 ctx := context.Background()
M internal/usecase/repos_write.go +2 -2
@@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
4646
4747 // Update changes the description, the visibility, the default branch and the
4848 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49-// (see adminAccess for what the others learn). At least one field must be set,
49+// (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
5050 // else domain.ErrInvalid. A default branch that does not exist gives
5151 // domain.ErrPreconditionFailed; nothing is written before every check passed.
5252 // The metadata is written first, then HEAD, and RepoUpdated is published after
@@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
174174
175175 // Delete moves the repository directory to the trash, removes its metadata
176176 // and publishes RepoDeleted. The principal needs repo:admin on it (see
177-// adminAccess). A repository whose directory is already gone, left by a
177+// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
178178 // delete that failed half way, loses its metadata.
179179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
180180 repo, err := lookupRepo(ctx, r.meta, name)
@@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
46 46
47 // Update changes the description, the visibility, the default branch and the47 // Update changes the description, the visibility, the default branch and the
48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it48 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49-// (see adminAccess for what the others learn). At least one field must be set,49+// (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
50 // else domain.ErrInvalid. A default branch that does not exist gives50 // else domain.ErrInvalid. A default branch that does not exist gives
51 // domain.ErrPreconditionFailed; nothing is written before every check passed.51 // domain.ErrPreconditionFailed; nothing is written before every check passed.
52 // The metadata is written first, then HEAD, and RepoUpdated is published after52 // The metadata is written first, then HEAD, and RepoUpdated is published after
@@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
174 174
175 // Delete moves the repository directory to the trash, removes its metadata175 // Delete moves the repository directory to the trash, removes its metadata
176 // and publishes RepoDeleted. The principal needs repo:admin on it (see176 // and publishes RepoDeleted. The principal needs repo:admin on it (see
177-// adminAccess). A repository whose directory is already gone, left by a177+// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
178 // delete that failed half way, loses its metadata.178 // delete that failed half way, loses its metadata.
179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
180 repo, err := lookupRepo(ctx, r.meta, name)180 repo, err := lookupRepo(ctx, r.meta, name)
M internal/usecase/repos_write_test.go +26 -0
@@ -165,6 +165,32 @@ func TestUpdate(t *testing.T) {
165165 })
166166 }
167167
168+ t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
169+ t.Parallel()
170+ modes := []domain.MergeMode{domain.MergeModeSquash}
171+ block := true
172+ for name, params := range map[string]ports.UpdateRepoParams{
173+ "default branch": {DefaultBranch: ptr("dev")},
174+ "visibility": {Visibility: ptr(domain.VisibilityPublic)},
175+ "description": {Description: ptr("x")},
176+ "merge modes": {AllowedMergeModes: &modes},
177+ "checks rule": {FailingChecksBlock: &block},
178+ } {
179+ f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
180+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
181+ _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
182+ assert.ErrorIs(t, err, domain.ErrForbidden, name)
183+ }
184+ })
185+
186+ t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
187+ t.Parallel()
188+ f := newFixture(t) // no Trash, no Delete
189+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
190+ err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
191+ assert.ErrorIs(t, err, domain.ErrForbidden)
192+ })
193+
168194 t.Run("a missing repository is not found", func(t *testing.T) {
169195 t.Parallel()
170196 f := newFixture(t)
@@ -165,6 +165,32 @@ func TestUpdate(t *testing.T) {
165 })165 })
166 }166 }
167 167
168+ t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
169+ t.Parallel()
170+ modes := []domain.MergeMode{domain.MergeModeSquash}
171+ block := true
172+ for name, params := range map[string]ports.UpdateRepoParams{
173+ "default branch": {DefaultBranch: ptr("dev")},
174+ "visibility": {Visibility: ptr(domain.VisibilityPublic)},
175+ "description": {Description: ptr("x")},
176+ "merge modes": {AllowedMergeModes: &modes},
177+ "checks rule": {FailingChecksBlock: &block},
178+ } {
179+ f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
180+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
181+ _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
182+ assert.ErrorIs(t, err, domain.ErrForbidden, name)
183+ }
184+ })
185+
186+ t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
187+ t.Parallel()
188+ f := newFixture(t) // no Trash, no Delete
189+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
190+ err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
191+ assert.ErrorIs(t, err, domain.ErrForbidden)
192+ })
193+
168 t.Run("a missing repository is not found", func(t *testing.T) {194 t.Run("a missing repository is not found", func(t *testing.T) {
169 t.Parallel()195 t.Parallel()
170 f := newFixture(t)196 f := newFixture(t)
M internal/usecase/sessions.go +2 -2
@@ -74,13 +74,13 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc
7474 }
7575
7676 // requireOwner is the access rule of the session API: root, or a stored token
77-// with every scope and no repository list.
77+// with every scope, no repository list and no branch prefixes.
7878 func requireOwner(p domain.Principal) error {
7979 switch {
8080 case p.IsAnonymous():
8181 return domain.ErrUnauthenticated
8282 case !p.HasOwnerRights():
83- return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden)
83+ return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
8484 }
8585 return nil
8686 }
@@ -74,13 +74,13 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc
74 }74 }
75 75
76 // requireOwner is the access rule of the session API: root, or a stored token76 // requireOwner is the access rule of the session API: root, or a stored token
77-// with every scope and no repository list.77+// with every scope, no repository list and no branch prefixes.
78 func requireOwner(p domain.Principal) error {78 func requireOwner(p domain.Principal) error {
79 switch {79 switch {
80 case p.IsAnonymous():80 case p.IsAnonymous():
81 return domain.ErrUnauthenticated81 return domain.ErrUnauthenticated
82 case !p.HasOwnerRights():82 case !p.HasOwnerRights():
83- return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden)83+ return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
84 }84 }
85 return nil85 return nil
86 }86 }
M internal/usecase/tokens.go +26 -9
@@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session
5454 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
5555 // cannot create a token more powerful than itself: every scope must be one it
5656 // holds, the repositories must lie within its list (a limited token cannot
57-// create an unlimited one) and the expiry must not outlast its own. Violations
57+// create an unlimited one), the same for branch prefixes (each must start
58+// with one of its own) and the expiry must not outlast its own. Violations
5859 // give domain.ErrForbidden. Root has no such limits. Invalid settings give
5960 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which
6061 // are reserved, in any case and around spaces), an unknown scope
@@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr
8889 log := zerowrap.FromCtx(ctx)
8990 log.Info().
9091 Str("token_id", tok.ID).Str("label", tok.Label).
91- Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
92+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)).
9293 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
9394 Msg("token created")
9495 return ports.CreatedToken{Token: tok, Secret: secret}, nil
@@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
122123 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
123124 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
124125 }
126+ prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes)
127+ if err != nil {
128+ return domain.Token{}, err
129+ }
125130 tok := domain.Token{
126- Label: params.Label,
127- CreatedAt: now,
128- ExpiresAt: params.ExpiresAt.UTC(),
131+ Label: params.Label,
132+ BranchPrefixes: prefixes,
133+ CreatedAt: now,
134+ ExpiresAt: params.ExpiresAt.UTC(),
129135 }
130136 // Canonical order, no duplicates.
131137 for _, s := range domain.AllScopes() {
@@ -143,8 +149,9 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
143149 }
144150
145151 // checkNoEscalation enforces that tok is within the rights of p: every scope
146-// held by p, the repositories within its list and the expiry not beyond its
147-// own. Root has no limits.
152+// held by p, the repositories within its list, the branch prefixes within its
153+// own (a limited p only makes tokens that are limited too, each prefix under
154+// one of its own) and the expiry not beyond its own. Root has no limits.
148155 func checkNoEscalation(p domain.Principal, tok domain.Token) error {
149156 if p.IsRoot() {
150157 return nil
@@ -164,6 +171,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164171 }
165172 }
166173 }
174+ if p.IsBranchLimited() {
175+ if len(tok.BranchPrefixes) == 0 {
176+ return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden)
177+ }
178+ for _, bp := range tok.BranchPrefixes {
179+ if !p.CoversBranchPrefix(bp) {
180+ return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp)
181+ }
182+ }
183+ }
167184 if exp := p.ExpiresAt(); !exp.IsZero() {
168185 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
169186 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
@@ -173,7 +190,7 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
173190 }
174191
175192 // List returns the stored tokens the principal could have created, oldest
176-// first: those whose scopes, repositories and expiry lie within its own (the
193+// first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the
177194 // no-escalation rule of Create). Root sees every token. It needs token:admin.
178195 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
179196 if err := requireTokenAdmin(p); err != nil {
@@ -195,7 +212,7 @@ func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token,
195212 // Create), so that it does not show itself. Root may revoke any token.
196213 //
197214 // A token that has owner rights (HasOwnerRights: every scope, no repository
198-// list) could open web sessions, so revoking it also signs every browser out,
215+// list and no branch prefixes) could open web sessions, so revoking it also signs every browser out,
199216 // as `ermite web logout --all` does: web sessions are not tied to the token
200217 // that opened them. The revocation holds even if signing out fails, which is
201218 // then reported as an error.
@@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session
54 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token54 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
55 // cannot create a token more powerful than itself: every scope must be one it55 // cannot create a token more powerful than itself: every scope must be one it
56 // holds, the repositories must lie within its list (a limited token cannot56 // holds, the repositories must lie within its list (a limited token cannot
57-// create an unlimited one) and the expiry must not outlast its own. Violations57+// create an unlimited one), the same for branch prefixes (each must start
58+// with one of its own) and the expiry must not outlast its own. Violations
58 // give domain.ErrForbidden. Root has no such limits. Invalid settings give59 // give domain.ErrForbidden. Root has no such limits. Invalid settings give
59 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which60 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which
60 // are reserved, in any case and around spaces), an unknown scope61 // are reserved, in any case and around spaces), an unknown scope
@@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr
88 log := zerowrap.FromCtx(ctx)89 log := zerowrap.FromCtx(ctx)
89 log.Info().90 log.Info().
90 Str("token_id", tok.ID).Str("label", tok.Label).91 Str("token_id", tok.ID).Str("label", tok.Label).
91- Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).92+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)).
92 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).93 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
93 Msg("token created")94 Msg("token created")
94 return ports.CreatedToken{Token: tok, Secret: secret}, nil95 return ports.CreatedToken{Token: tok, Secret: secret}, nil
@@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
122 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {123 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
123 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)124 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
124 }125 }
126+ prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes)
127+ if err != nil {
128+ return domain.Token{}, err
129+ }
125 tok := domain.Token{130 tok := domain.Token{
126- Label: params.Label,131+ Label: params.Label,
127- CreatedAt: now,132+ BranchPrefixes: prefixes,
128- ExpiresAt: params.ExpiresAt.UTC(),133+ CreatedAt: now,
134+ ExpiresAt: params.ExpiresAt.UTC(),
129 }135 }
130 // Canonical order, no duplicates.136 // Canonical order, no duplicates.
131 for _, s := range domain.AllScopes() {137 for _, s := range domain.AllScopes() {
@@ -143,8 +149,9 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
143 }149 }
144 150
145 // checkNoEscalation enforces that tok is within the rights of p: every scope151 // checkNoEscalation enforces that tok is within the rights of p: every scope
146-// held by p, the repositories within its list and the expiry not beyond its152+// held by p, the repositories within its list, the branch prefixes within its
147-// own. Root has no limits.153+// own (a limited p only makes tokens that are limited too, each prefix under
154+// one of its own) and the expiry not beyond its own. Root has no limits.
148 func checkNoEscalation(p domain.Principal, tok domain.Token) error {155 func checkNoEscalation(p domain.Principal, tok domain.Token) error {
149 if p.IsRoot() {156 if p.IsRoot() {
150 return nil157 return nil
@@ -164,6 +171,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164 }171 }
165 }172 }
166 }173 }
174+ if p.IsBranchLimited() {
175+ if len(tok.BranchPrefixes) == 0 {
176+ return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden)
177+ }
178+ for _, bp := range tok.BranchPrefixes {
179+ if !p.CoversBranchPrefix(bp) {
180+ return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp)
181+ }
182+ }
183+ }
167 if exp := p.ExpiresAt(); !exp.IsZero() {184 if exp := p.ExpiresAt(); !exp.IsZero() {
168 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {185 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
169 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)186 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
@@ -173,7 +190,7 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
173 }190 }
174 191
175 // List returns the stored tokens the principal could have created, oldest192 // List returns the stored tokens the principal could have created, oldest
176-// first: those whose scopes, repositories and expiry lie within its own (the193+// first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the
177 // no-escalation rule of Create). Root sees every token. It needs token:admin.194 // no-escalation rule of Create). Root sees every token. It needs token:admin.
178 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {195 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
179 if err := requireTokenAdmin(p); err != nil {196 if err := requireTokenAdmin(p); err != nil {
@@ -195,7 +212,7 @@ func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token,
195 // Create), so that it does not show itself. Root may revoke any token.212 // Create), so that it does not show itself. Root may revoke any token.
196 //213 //
197 // A token that has owner rights (HasOwnerRights: every scope, no repository214 // A token that has owner rights (HasOwnerRights: every scope, no repository
198-// list) could open web sessions, so revoking it also signs every browser out,215+// list and no branch prefixes) could open web sessions, so revoking it also signs every browser out,
199 // as `ermite web logout --all` does: web sessions are not tied to the token216 // as `ermite web logout --all` does: web sessions are not tied to the token
200 // that opened them. The revocation holds even if signing out fails, which is217 // that opened them. The revocation holds even if signing out fails, which is
201 // then reported as an error.218 // then reported as an error.
M internal/usecase/tokens_test.go +117 -0
@@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) {
177177 })
178178 }
179179
180+ adminOn := func(prefixes []string) domain.Principal {
181+ return domain.TokenPrincipal(domain.Token{
182+ ID: "creator",
183+ Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite),
184+ BranchPrefixes: prefixes,
185+ })
186+ }
187+
180188 tests := []struct {
181189 name string
182190 principal domain.Principal
@@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) {
211219 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
212220 {"root: everything", domain.Root(),
213221 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
222+ {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}),
223+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil},
224+ {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}),
225+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil},
226+ {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}),
227+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil},
228+ {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}),
229+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
230+ {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}),
231+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden},
232+ {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}),
233+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden},
234+ {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}),
235+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden},
236+ {"unlimited creator may limit branches", admin(nil, time.Time{}),
237+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil},
214238 }
215239 for _, tt := range tests {
216240 t.Run(tt.name, func(t *testing.T) {
@@ -283,6 +307,7 @@ func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) {
283307 "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true},
284308 "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false},
285309 "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false},
310+ "branch prefixes": {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false},
286311 } {
287312 t.Run(name, func(t *testing.T) {
288313 t.Parallel()
@@ -551,3 +576,95 @@ func TestAuthenticate(t *testing.T) {
551576 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
552577 })
553578 }
579+
580+func TestTokensCreateBranchPrefixes(t *testing.T) {
581+ t.Parallel()
582+ ctx := context.Background()
583+
584+ t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) {
585+ t.Parallel()
586+ f := newTokenFixture(t)
587+ f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool {
588+ return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes)
589+ })).Return(nil)
590+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
591+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"},
592+ })
593+ require.NoError(t, err)
594+ assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes)
595+ })
596+
597+ t.Run("no prefixes stays unlimited", func(t *testing.T) {
598+ t.Parallel()
599+ f := newTokenFixture(t)
600+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
601+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
602+ require.NoError(t, err)
603+ assert.Empty(t, got.Token.BranchPrefixes)
604+ })
605+
606+ t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) {
607+ t.Parallel()
608+ nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"}
609+ for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} {
610+ f := newTokenFixture(t) // the store expects no Create
611+ _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
612+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad,
613+ })
614+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
615+ }
616+ })
617+
618+ t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) {
619+ t.Parallel()
620+ f := newTokenFixture(t) // the store expects no Create
621+ corrupt := domain.TokenPrincipal(domain.Token{
622+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""},
623+ })
624+ _, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{
625+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"},
626+ })
627+ assert.ErrorIs(t, err, domain.ErrForbidden)
628+ })
629+
630+ t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) {
631+ t.Parallel()
632+ ctx := context.Background()
633+ creator := domain.TokenPrincipal(domain.Token{
634+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
635+ })
636+ inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
637+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
638+ sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}
639+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
640+
641+ f := newTokenFixture(t)
642+ for _, tok := range []domain.Token{wide, sibling, free} {
643+ f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once()
644+ }
645+ // Out of reach: not found, like an unknown id, and nothing is revoked.
646+ for _, id := range []string{"wide", "sib", "free"} {
647+ assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id)
648+ }
649+ // Within its prefixes it revokes, and no web session is signed out.
650+ f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once()
651+ f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once()
652+ require.NoError(t, f.tokens.Revoke(ctx, creator, "in"))
653+ })
654+
655+ t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) {
656+ t.Parallel()
657+ f := newTokenFixture(t)
658+ mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
659+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
660+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
661+ f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil)
662+ p := domain.TokenPrincipal(domain.Token{
663+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
664+ })
665+ got, err := f.tokens.List(ctx, p)
666+ require.NoError(t, err)
667+ require.Len(t, got, 1)
668+ assert.Equal(t, "mine", got[0].ID)
669+ })
670+}
@@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) {
177 })177 })
178 }178 }
179 179
180+ adminOn := func(prefixes []string) domain.Principal {
181+ return domain.TokenPrincipal(domain.Token{
182+ ID: "creator",
183+ Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite),
184+ BranchPrefixes: prefixes,
185+ })
186+ }
187+
180 tests := []struct {188 tests := []struct {
181 name string189 name string
182 principal domain.Principal190 principal domain.Principal
@@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) {
211 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},219 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
212 {"root: everything", domain.Root(),220 {"root: everything", domain.Root(),
213 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},221 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
222+ {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}),
223+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil},
224+ {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}),
225+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil},
226+ {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}),
227+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil},
228+ {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}),
229+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
230+ {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}),
231+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden},
232+ {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}),
233+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden},
234+ {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}),
235+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden},
236+ {"unlimited creator may limit branches", admin(nil, time.Time{}),
237+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil},
214 }238 }
215 for _, tt := range tests {239 for _, tt := range tests {
216 t.Run(tt.name, func(t *testing.T) {240 t.Run(tt.name, func(t *testing.T) {
@@ -283,6 +307,7 @@ func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) {
283 "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true},307 "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true},
284 "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false},308 "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false},
285 "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false},309 "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false},
310+ "branch prefixes": {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false},
286 } {311 } {
287 t.Run(name, func(t *testing.T) {312 t.Run(name, func(t *testing.T) {
288 t.Parallel()313 t.Parallel()
@@ -551,3 +576,95 @@ func TestAuthenticate(t *testing.T) {
551 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)576 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
552 })577 })
553 }578 }
579+
580+func TestTokensCreateBranchPrefixes(t *testing.T) {
581+ t.Parallel()
582+ ctx := context.Background()
583+
584+ t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) {
585+ t.Parallel()
586+ f := newTokenFixture(t)
587+ f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool {
588+ return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes)
589+ })).Return(nil)
590+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
591+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"},
592+ })
593+ require.NoError(t, err)
594+ assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes)
595+ })
596+
597+ t.Run("no prefixes stays unlimited", func(t *testing.T) {
598+ t.Parallel()
599+ f := newTokenFixture(t)
600+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
601+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
602+ require.NoError(t, err)
603+ assert.Empty(t, got.Token.BranchPrefixes)
604+ })
605+
606+ t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) {
607+ t.Parallel()
608+ nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"}
609+ for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} {
610+ f := newTokenFixture(t) // the store expects no Create
611+ _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
612+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad,
613+ })
614+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
615+ }
616+ })
617+
618+ t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) {
619+ t.Parallel()
620+ f := newTokenFixture(t) // the store expects no Create
621+ corrupt := domain.TokenPrincipal(domain.Token{
622+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""},
623+ })
624+ _, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{
625+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"},
626+ })
627+ assert.ErrorIs(t, err, domain.ErrForbidden)
628+ })
629+
630+ t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) {
631+ t.Parallel()
632+ ctx := context.Background()
633+ creator := domain.TokenPrincipal(domain.Token{
634+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
635+ })
636+ inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
637+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
638+ sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}
639+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
640+
641+ f := newTokenFixture(t)
642+ for _, tok := range []domain.Token{wide, sibling, free} {
643+ f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once()
644+ }
645+ // Out of reach: not found, like an unknown id, and nothing is revoked.
646+ for _, id := range []string{"wide", "sib", "free"} {
647+ assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id)
648+ }
649+ // Within its prefixes it revokes, and no web session is signed out.
650+ f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once()
651+ f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once()
652+ require.NoError(t, f.tokens.Revoke(ctx, creator, "in"))
653+ })
654+
655+ t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) {
656+ t.Parallel()
657+ f := newTokenFixture(t)
658+ mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
659+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
660+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
661+ f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil)
662+ p := domain.TokenPrincipal(domain.Token{
663+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
664+ })
665+ got, err := f.tokens.List(ctx, p)
666+ require.NoError(t, err)
667+ require.Len(t, got, 1)
668+ assert.Equal(t, "mine", got[0].ID)
669+ })
670+}
M proto/ermite/storage/repo.proto +3 -0
@@ -53,4 +53,7 @@ message Token {
5353 google.protobuf.Timestamp created_at = 7;
5454 // Unset until the token is first used.
5555 google.protobuf.Timestamp last_used_at = 8;
56+ // Branch prefixes, each ending with "/", that the token may write; empty
57+ // means every branch. Added in storage schema 2.
58+ repeated string branch_prefixes = 9;
5659 }
@@ -53,4 +53,7 @@ message Token {
53 google.protobuf.Timestamp created_at = 7;53 google.protobuf.Timestamp created_at = 7;
54 // Unset until the token is first used.54 // Unset until the token is first used.
55 google.protobuf.Timestamp last_used_at = 8;55 google.protobuf.Timestamp last_used_at = 8;
56+ // Branch prefixes, each ending with "/", that the token may write; empty
57+ // means every branch. Added in storage schema 2.
58+ repeated string branch_prefixes = 9;
56 }59 }
M skills/ermite/CLI.md +5 -3
@@ -20,7 +20,7 @@ Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created
2020
2121 | Command | Scope | Notes |
2222 |---|---|---|
23-| `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope |
23+| `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--branch-prefix P] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope |
2424 | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed |
2525 | `token revoke <id>` | `token:admin` | |
2626
@@ -44,7 +44,9 @@ Scopes:
4444 | `token:admin` | create, list and revoke tokens |
4545 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
4646
47-A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `expires_at`, `created_at`, `last_used_at`.
47+A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `branch_prefixes`, `expires_at`, `created_at`, `last_used_at`.
48+
49+`--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
4850
4951 ## Merge requests
5052
@@ -80,5 +82,5 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
8082 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
8183 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
8284 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
83-| `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret |
85+| `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
8486 | `web logout --all` | signs every browser out |
@@ -20,7 +20,7 @@ Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created
20 20
21 | Command | Scope | Notes |21 | Command | Scope | Notes |
22 |---|---|---|22 |---|---|---|
23-| `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope |23+| `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--branch-prefix P] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope |
24 | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed |24 | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed |
25 | `token revoke <id>` | `token:admin` | |25 | `token revoke <id>` | `token:admin` | |
26 26
@@ -44,7 +44,9 @@ Scopes:
44 | `token:admin` | create, list and revoke tokens |44 | `token:admin` | create, list and revoke tokens |
45 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |45 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
46 46
47-A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `expires_at`, `created_at`, `last_used_at`.47+A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `branch_prefixes`, `expires_at`, `created_at`, `last_used_at`.
48+
49+`--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
48 50
49 ## Merge requests51 ## Merge requests
50 52
@@ -80,5 +82,5 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
80 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |82 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
81 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |83 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
82 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |84 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
83-| `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret |85+| `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
84 | `web logout --all` | signs every browser out |86 | `web logout --all` | signs every browser out |

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close