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
3 changes: 0 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,6 @@
the committer falls back to the author (matching git's default behaviour). The
synthetic fallback (`construct user <user@construct.local>`) is only used when
no author identity at all is available on the host, and triggers a warning.
- A `commit-msg` hook is injected into the container at startup via
`core.hooksPath`. It appends a `Generated by construct` git trailer to every
commit message (idempotent — safe with amend and rebase).

---

Expand Down
2 changes: 1 addition & 1 deletion docs/spec/construct-agents-md.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ not override a project-level `AGENTS.md` or `CLAUDE.md` in the workspace.

| File | Change |
|---|---|
| `internal/runner/runner.go` | `CONSTRUCT=1` always injected; `generatedEntrypoint()` always writes `~/.config/opencode/AGENTS.md`, appending port rules only when `CONSTRUCT_PORTS` is set; also writes git identity hook and sets `core.hooksPath` (see `docs/spec/git-identity.md`) |
| `internal/runner/runner.go` | `CONSTRUCT=1` always injected; `generatedEntrypoint()` always writes `~/.config/opencode/AGENTS.md`, appending port rules only when `CONSTRUCT_PORTS` is set |
| `internal/runner/runner_test.go` | Tests updated to reflect always-present `CONSTRUCT=1` and always-present `AGENTS.md` |
| `docs/spec/construct-agents-md.md` | This document |

38 changes: 3 additions & 35 deletions docs/spec/git-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ attribution that comes from the developer's own git identity.
## Solution

construct reads the host user's git identity at launch and injects it into the
container. Every commit the agent makes is attributed to the real developer. A
`commit-msg` hook appended to every commit message makes it clear the commit was
generated by construct.
container. Every commit the agent makes is attributed to the real developer.

## Behaviour

Expand Down Expand Up @@ -60,37 +58,7 @@ GIT_COMMITTER_EMAIL=<resolved>
The previous hard-coded `construct agent / agent@construct.local` identity is
removed entirely.

### 3. Commit-msg hook

`generatedEntrypoint()` writes a `commit-msg` hook into the container at
startup:

```
/home/agent/.githooks/commit-msg
```

and configures git to use it globally:

```sh
git config --global core.hooksPath /home/agent/.githooks
```

The hook appends a `Generated by construct` trailer to every commit message,
following the git trailer convention (a blank line separates the body from the
trailer block). The hook is idempotent: it checks for the presence of the
trailer before appending, so amend and rebase do not produce duplicates.

Resulting commit message format:

```
feat: add user login

Implement OAuth2 flow with token refresh.

Generated by construct
```

### 4. Scope
### 3. Scope

This is a runner-level change. It applies to all tools.

Expand All @@ -104,7 +72,7 @@ No files are written to the host or to the workspace repo.

| File | Change |
|---|---|
| `internal/runner/runner.go` | `hostGitIdentity()`: resolves author/committer separately, honouring host env vars with committer falling back to author; `buildRunArgs`: injects resolved values; `generatedEntrypoint()`: writes `/home/agent/.githooks/commit-msg` and sets `git config --global core.hooksPath` |
| `internal/runner/runner.go` | `hostGitIdentity()`: resolves author/committer separately, honouring host env vars with committer falling back to author; `buildRunArgs`: injects resolved values |
| `docs/spec/git-identity.md` | This document |
| `CHANGELOG.md` | Entry under `## [Unreleased]` |

6 changes: 1 addition & 5 deletions docs/spec/image-build-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,7 @@ off to the tool:
`~/.config/opencode/opencode.json` with the playwright MCP server entry.
Otherwise deletes the file, preventing stale config from persisting across
runs on the same home volume.
3. **Git identity** — sets `git config --global core.hooksPath
/home/agent/.githooks` and writes a `commit-msg` hook that appends a
`Generated by construct` trailer to every commit message (idempotent).
The host user's git identity is supplied via `GIT_AUTHOR_*` /
3. **Git identity** — the host user's git identity is supplied via `GIT_AUTHOR_*` /
`GIT_COMMITTER_*` env vars set in `buildRunArgs` (see
`docs/spec/git-identity.md`).
4. **Exec** — hands off to the tool command (`exec "$@"`).
Expand All @@ -109,7 +106,6 @@ Examples: `construct-ui-opencode`, `construct-go-opencode`.
| AI tool binary (`npm install -g opencode-ai`) | Tool |
| Entrypoint / secrets handling | Tool |
| MCP config write/delete logic | Tool (entrypoint) |
| Git identity hook (`commit-msg`, `core.hooksPath`) | Tool (entrypoint) |
| `DOCKER_HOST` env var (runtime, not baked in) | Container run args |
| Git author/committer identity (host-read at launch) | Container run args |
| Workspace bind-mount | Container run args |
Expand Down
2 changes: 1 addition & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ The agent does **not** have access to:
| **Mitigation** | No git credential helper is wired up by construct. The agent would have to discover the token from its environment and construct the remote URL manually (e.g. `https://x-access-token:$TOKEN@github.com/...`). This is non-trivial but not difficult for a capable model. SSH keys are **not** mounted into the container, so SSH-based remotes are unreachable. |
| **Residual risk** | No token with git push scope is injected by default. If a user supplies a GitHub token via `config set`, the agent could use it for HTTPS git operations. |
| **Recommendation** | Use a token scoped to the minimum required permissions. A GitHub fine-grained PAT with read-only or repo-specific access limits blast radius. Avoid using tokens that have push access to repositories beyond the one you are actively working on. Note: recommending SSH remotes is **not** a useful mitigation here — SSH keys are not mounted, so the attack surface is HTTPS+token only. |
| **Attribution note** | construct injects the host user's real git identity (`user.name` / `user.email`) as `GIT_AUTHOR_*` and `GIT_COMMITTER_*`. Any commits the agent makes — including any it pushes — will carry the developer's real name and email, plus a `Generated by construct` trailer. Users should be aware that agent-authored commits are attributable to them in git history. |
| **Attribution note** | construct injects the host user's real git identity (`user.name` / `user.email`) as `GIT_AUTHOR_*` and `GIT_COMMITTER_*`. Any commits the agent makes — including any it pushes — will carry the developer's real name and email. Users should be aware that agent-authored commits are attributable to them in git history. |
| **vs. host baseline** | On the host, the agent has access to the user's full git credential store (`~/.gitconfig`, credential helper, SSH keys, `~/.netrc`), giving push access to every repository the user can reach. construct limits this to explicitly injected tokens only. |

---
Expand Down
16 changes: 1 addition & 15 deletions internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,7 @@ func buildRunArgs(cfg *Config, dindInst *dind.Instance, image, sessionID, homeVo
// Git identity — use the host user's real identity so commits are attributed
// to the person who ran construct. Author and committer are read separately
// so that a host with distinct GIT_AUTHOR_* / GIT_COMMITTER_* env vars is
// faithfully mirrored. The commit-msg hook (written by the entrypoint)
// appends a "Generated by construct" trailer to every message.
// faithfully mirrored.
"-e", "GIT_AUTHOR_NAME="+authorName,
"-e", "GIT_AUTHOR_EMAIL="+authorEmail,
"-e", "GIT_COMMITTER_NAME="+committerName,
Expand Down Expand Up @@ -936,19 +935,6 @@ func generatedEntrypoint() string {
"The host connects to it via http://localhost:${CONSTRUCT_SERVE_PORT}.\n" +
"AGENTSEOF\n" +
"fi\n" +
"# Set up a global commit-msg hook that appends a 'Generated by construct'\n" +
"# git trailer to every commit message. The hook is idempotent: it checks for\n" +
"# the trailer before appending so amend/rebase does not produce duplicates.\n" +
"mkdir -p \"${HOME}/.githooks\"\n" +
"cat > \"${HOME}/.githooks/commit-msg\" << 'HOOKEOF'\n" +
"#!/bin/sh\n" +
"trailer='Generated by construct'\n" +
"if ! grep -qF \"$trailer\" \"$1\"; then\n" +
" printf '\\n%s\\n' \"$trailer\" >> \"$1\"\n" +
"fi\n" +
"HOOKEOF\n" +
"chmod +x \"${HOME}/.githooks/commit-msg\"\n" +
"git config --global core.hooksPath \"${HOME}/.githooks\"\n" +
"exec \"$@\"\n"
}

Expand Down
40 changes: 5 additions & 35 deletions internal/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1609,42 +1609,12 @@ func TestHostGitIdentity_CommitterFallsBackToAuthor(t *testing.T) {
}
}

// TestGeneratedEntrypoint_ContainsCommitMsgHook verifies that the generated
// entrypoint script sets up the commit-msg hook and configures core.hooksPath.
func TestGeneratedEntrypoint_ContainsCommitMsgHook(t *testing.T) {
// TestGeneratedEntrypoint_EndsWithExec verifies that the generated entrypoint
// script ends with exec "$@".
func TestGeneratedEntrypoint_EndsWithExec(t *testing.T) {
script := generatedEntrypoint()
checks := []struct {
desc string
snippet string
}{
{"creates .githooks dir", ".githooks"},
{"writes commit-msg hook file", "commit-msg"},
{"hook appends Generated by construct trailer", "Generated by construct"},
{"hook is idempotent (grep check)", "grep -qF"},
{"sets core.hooksPath globally", "core.hooksPath"},
{"hook is executable", "chmod +x"},
}
for _, c := range checks {
if !strings.Contains(script, c.snippet) {
t.Errorf("entrypoint: expected %s (snippet %q not found)", c.desc, c.snippet)
}
}
}

// TestGeneratedEntrypoint_HookBeforeExec verifies the commit-msg hook setup
// appears before the final exec line.
func TestGeneratedEntrypoint_HookBeforeExec(t *testing.T) {
script := generatedEntrypoint()
hookIdx := strings.Index(script, "commit-msg")
execIdx := strings.LastIndex(script, `exec "$@"`)
if hookIdx == -1 {
t.Fatal("commit-msg hook block not found in entrypoint")
}
if execIdx == -1 {
t.Fatal(`exec "$@" not found in entrypoint`)
}
if hookIdx > execIdx {
t.Error("commit-msg hook block appears after exec line; it must come before")
if !strings.Contains(script, `exec "$@"`) {
t.Error(`entrypoint: expected exec "$@" line not found`)
}
}

Expand Down
Loading