diff --git a/CHANGELOG.md b/CHANGELOG.md index 1812edc..f7ab565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,9 +75,6 @@ the committer falls back to the author (matching git's default behaviour). The synthetic fallback (`construct user `) 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). --- diff --git a/docs/spec/construct-agents-md.md b/docs/spec/construct-agents-md.md index 02dc3ac..fbb7885 100644 --- a/docs/spec/construct-agents-md.md +++ b/docs/spec/construct-agents-md.md @@ -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 | diff --git a/docs/spec/git-identity.md b/docs/spec/git-identity.md index 0a3fadc..191c5e2 100644 --- a/docs/spec/git-identity.md +++ b/docs/spec/git-identity.md @@ -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 @@ -60,37 +58,7 @@ GIT_COMMITTER_EMAIL= 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. @@ -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]` | diff --git a/docs/spec/image-build-layers.md b/docs/spec/image-build-layers.md index e83a4da..70d44eb 100644 --- a/docs/spec/image-build-layers.md +++ b/docs/spec/image-build-layers.md @@ -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 "$@"`). @@ -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 | diff --git a/docs/threat-model.md b/docs/threat-model.md index 54d6b02..f925145 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -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. | --- diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 8d47a63..b2a0eb8 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -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, @@ -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" } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 3e13593..7dbb24d 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -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`) } }