Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ It provides:
(blobs + commits, CAS ref updates, `.gitattributes`-driven LFS cleaning) — so
products never need local clones.
- A `read` / `write` / `admin` permission model enforced before every Git operation.
- Per-repo path policies (block a path subtree from being added/modified; deletes
allowed), enforced identically on api commits and on pushes via a pre-receive hook.
- Push webhooks, signed and delivered off the push path.

Out of scope unless explicitly requested: issues, pull requests, wikis, CI/actions,
Expand Down Expand Up @@ -112,6 +114,18 @@ Never:
- Give Git subprocesses broad filesystem access.
- Hand an SSH client an interactive shell or pty.

### Write policy (path policies)

Write policy has exactly two enforcement points that must never drift: the
repositories service checks api commit operations, and the pre-receive hook
checks pushes — both through the single matcher in `domain`. The hook is the
server binary itself in hook mode (`headlessgit hook pre-receive`): one shim at
`<REPO_ROOT>/.hooks/pre-receive` execs `$HEADLESSGIT_BIN`, `core.hooksPath` is
injected per-push via `GIT_CONFIG_*` env (repos hold no hooks on disk), and the
transports pass policies via `HEADLESSGIT_POLICIES` env. Hook enforcement fails
closed: any error rejects the push. New policy kinds extend the `kind` column
and both enforcement points, never a hook script.

## Conventions

Prefer simple Go: small interfaces at module boundaries, context-aware I/O, explicit
Expand Down
48 changes: 37 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Basically, this is a Git layer of infrastructure you'd put _underneath_ a projec
- A small **control API**, RESTful api to manage repositories, users, SSH keys, tokens, and permissions.
- A **repo content API** — list trees, read files, download zip/tar.gz archives, and create commits over REST, so your backend never needs a local clone.
- Simple **permission model** (`read` / `write` / `admin`) enforced before every Git operation.
- **Path policies** — block chosen paths from ever being committed, enforced identically on API commits and `git push` (via a pre-receive hook).
- **Push webhooks** — signed deliveries on every ref change, pushed or committed via the API.
- Bare-repository storage on a filesystem, with SQLite for metadata.

Expand Down Expand Up @@ -130,17 +131,20 @@ Every request requires `Authorization: Bearer <ADMIN_TOKEN>`. Responses are enve

**Repositories & permissions**

| Method | Path | Body | Description |
| -------- | ----------------------------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). |
| `GET` | `/repositories/{id}` | — | Get repository metadata. |
| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). |
| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). |
| `GET` | `/repositories/{id}/permissions` | — | List collaborators. |
| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). |
| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. |
| `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. |
| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. |
| Method | Path | Body | Description |
| -------- | --------------------------------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `POST` | `/repositories` | `{ownerId, name, visibility}` | Create a repository (`visibility`: `public` \| `private`). |
| `GET` | `/repositories/{id}` | — | Get repository metadata. |
| `PUT` | `/repositories/{id}/visibility` | `{visibility}` | Change visibility (`public` \| `private`). |
| `DELETE` | `/repositories/{id}` | — | Delete a repository (row + bare repo). |
| `GET` | `/repositories/{id}/permissions` | — | List collaborators. |
| `PUT` | `/repositories/{id}/permissions` | `{userId, role}` | Grant/update a collaborator role (`read` \| `write` \| `admin`). |
| `DELETE` | `/repositories/{id}/permissions/{userId}` | — | Revoke a collaborator. |
| `POST` | `/repositories/{id}/webhooks` | `{url}` | Register a push webhook; the signing secret is returned **once**. |
| `DELETE` | `/repositories/{id}/webhooks/{hookId}` | — | Delete a webhook. |
| `GET` | `/repositories/{id}/path-policies` | — | List the repository's path policies. |
| `POST` | `/repositories/{id}/path-policies` | `{pattern, reason?}` | Block a path; see [Path policies](#path-policies). |
| `DELETE` | `/repositories/{id}/path-policies/{policyId}` | — | Remove a policy. |

**Repository contents & commits**

Expand Down Expand Up @@ -241,6 +245,28 @@ API commits dispatch the same signed [webhooks](#webhooks) as a `git push` — c

The control port also serves an unauthenticated `GET /healthz` readiness probe. It returns `200 {"status":"ok"}` when the database is reachable and `503 {"status":"unavailable"}` otherwise, and backs the container `HEALTHCHECK`.

## Path policies

A path policy blocks a path, and everything under it from being **added or modified** in a repository.

```sh
curl -H "Authorization: Bearer $TOKEN" -X POST \
http://localhost:4001/repositories/7/path-policies \
-d '{"pattern": "runtime/", "reason": "....."}'
```

Semantics:

- A pattern matches the exact path and its whole subtree: `runtime` blocks `runtime` and `runtime/state.json`, but not `runtime.md` or `src/runtime/x` — patterns anchor at the repo root and match whole path segments.
- **Deletes are always allowed**, so blocked content already in history can be cleaned up.
- Policies apply to **new commits only**.
- Enforcement is identical on both write paths: `POST /commits` returns `422 path_blocked`, and a `git push` is rejected by a pre-receive hook **before any ref moves** — every commit in the push is checked, so a blocked path added and removed within the same push is still refused. The client sees the `reason` verbatim:

```txt
remote: push rejected: "runtime/state.json" is blocked by policy (.....)
! [remote rejected] main -> main (pre-receive hook declined)
```

## Webhooks

Register a webhook on a repository and `headlessgit` will `POST` to it after every ref change — a `git push` or a commit created through the [content API](#writing-without-a-clone) produce identical events.
Expand Down
10 changes: 10 additions & 0 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"

Expand All @@ -23,6 +24,11 @@ import (
)

func main() {
// the server binary can also handle the hook mode (e.g. pre-reveive)
if len(os.Args) > 1 && os.Args[1] == "hook" {
os.Exit(runHook(os.Args[2:]))
}

config, err := config.Load()
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -138,3 +144,7 @@ func newLFSStorage(cfg config.LFSConfig) (storage.Storage, error) {
return nil, fmt.Errorf("unknown lfs storage type %q", cfg.StorageType)
}
}

func runHook(args []string) int {
return gitbackend.HookMain(args)
}
Binary file modified images/archive.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/commit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions internal/db/gen/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

97 changes: 97 additions & 0 deletions internal/db/gen/path_policies.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions internal/db/migrations/0005_path_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- +goose Up
create table if not exists path_policies (
id integer primary key,
repository_id integer not null,

pattern text not null,
kind text not null default 'block',
reason text,

created_at_unix_ms integer not null default (
CAST(unixepoch('subsec') * 1000 as integer)
),

unique (repository_id, pattern, kind),
foreign key (repository_id) references repositories(id) on delete cascade
);

create index if not exists idx_path_policies_repository_id on path_policies(repository_id);

-- +goose Down
drop index if exists idx_path_policies_repository_id;

drop table if exists path_policies;
16 changes: 16 additions & 0 deletions internal/db/queries/path_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- name: CreatePathPolicy :one
insert into path_policies (
repository_id, pattern, kind, reason
) values (
?, ?, ?, ?
)
on conflict (repository_id, pattern, kind) do nothing
returning *;

-- name: DeletePathPolicy :exec
delete from path_policies
where id=? and repository_id=?;

-- name: ListRepositoryPathPolicies :many
select * from path_policies
where repository_id=?;
102 changes: 102 additions & 0 deletions internal/domain/path_policy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package domain

import (
"encoding/json"
"strings"
"time"
)

const (
EnvPathPolicies = "HEADLESSGIT_POLICIES" // json array of {pattern, reason}
EnvHookBin = "HEADLESSGIT_BIN" // absolute path of the server binary
)

type envPathPolicy struct {
Pattern string `json:"pattern"`
Reason string `json:"reason,omitempty"`
}

// serializes policies for the hook env var
func EncodePathPolicies(policies []PathPolicy) (string, error) {
wire := make([]envPathPolicy, len(policies))
for i, p := range policies {
wire[i] = envPathPolicy{
Pattern: p.Pattern,
Reason: p.Reason,
}
}
out, err := json.Marshal(wire)
if err != nil {
return "", err
}
return string(out), nil
}

func HookEnv(binPath string, policies []PathPolicy) ([]string, error) {
env := []string{EnvHookBin + "=" + binPath}
if len(policies) > 0 {
encoded, err := EncodePathPolicies(policies)
if err != nil {
return nil, err
}
env = append(env, EnvPathPolicies+"="+encoded)
}
return env, nil
}

// parses the hook env var
func DecodePathPolicies(s string) ([]PathPolicy, error) {
if s == "" {
return nil, nil
}
var wire []envPathPolicy
if err := json.Unmarshal([]byte(s), &wire); err != nil {
return nil, err
}
out := make([]PathPolicy, len(wire))
for i, p := range wire {
out[i] = PathPolicy{Pattern: p.Pattern, Reason: p.Reason, Kind: PathPolicyBlock}
}
return out, nil
}

type PathPolicyKind string

const (
PathPolicyBlock PathPolicyKind = "block"
// lfs, size, etc etc later
)

type PathPolicy struct {
ID int64
RepositoryID int64
Pattern string
Kind PathPolicyKind
Reason string // optional, echoed in rejection messages
CreatedAt time.Time
}

func NormalizePathPattern(pattern string) (string, bool) {
p := strings.Trim(pattern, "/")
if p == "" || p == "." {
return "", false
}
for seg := range strings.SplitSeq(p, "/") {
if seg == "" || seg == "." || seg == ".." {
return "", false
}
}
if strings.ContainsAny(p, "\x00\n") {
return "", false
}
return p, true
}

func PathBlocked(patterns []string, path string) (string, bool) {
for _, pattern := range patterns {
if path == pattern || strings.HasPrefix(path, pattern+"/") {
return pattern, true
}
}
return "", false
}
Loading
Loading