Skip to content

Commit dc05818

Browse files
feat(workspace): operator Co-authored-by trailer (#158)
Workspace-shipped prepare-commit-msg hook for operator attribution. NOT a Claude Code plugin — the hook lives with the claude-cli provider itself, gets baked into the image, and is installed by the entrypoint. When SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL are both set, every workspace commit gets a 'Co-authored-by: NAME <EMAIL>' trailer so PRs the agent opens are attributed to the operator who sponsored the workspace. No-op when either env var is unset (backward compatible). Layout: providers/workspaces/claude-cli/ scripts/ entrypoint.sh git-hooks/ prepare-commit-msg <- new Changes: - New scripts/git-hooks/prepare-commit-msg. Skips merge/squash/template message sources to avoid polluting auto-generated messages. Does NOT skip 'commit' source so amend / cherry-pick / rebase still pick up the trailer when missing; idempotency check handles re-adds. Strips CR/LF from operator inputs (defense in depth). - Dockerfile: COPY scripts/git-hooks/ -> /opt/agentic/git-hooks/. - entrypoint.sh: compose hooks from /opt/agentic/git-hooks/ AND plugins/observability/hooks/git/ into $HOME/.git-hooks, then point core.hooksPath there. Two contributing sources, well-documented. Also lists SYN_OPERATOR_* in the env var header. - scripts/build-provider.py: stage_scripts now copies the entire scripts/ tree (was top-level files only) so subdirs like git-hooks/ reach the build context. - manifest.yaml: no plugin-include change (this is not a plugin). - New tests/integration/test_workspace_operator_attribution.py with 8 tests covering: no-env / with-env / one-env-only / idempotency / template-skip / merge-skip / amend-still-gets-trailer / newline- injection-sanitized. Resolves #158. Verification (rebuilt 2.1.126 image): - 8/8 new operator-attribution tests pass. - 27/27 pre-existing integration tests pass. - Composed hooks layout in container: $HOME/.git-hooks/ post-checkout -> plugins/observability/hooks/git/post-checkout post-commit -> plugins/observability/hooks/git/post-commit post-merge -> plugins/observability/hooks/git/post-merge post-rewrite -> plugins/observability/hooks/git/post-rewrite pre-push -> plugins/observability/hooks/git/pre-push prepare-commit-msg -> /opt/agentic/git-hooks/prepare-commit-msg - Observability git_commit events still emit on stderr.
1 parent 3c96e77 commit dc05818

8 files changed

Lines changed: 261 additions & 76 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Integration tests for the workspace's prepare-commit-msg hook.
2+
3+
The hook lives at:
4+
providers/workspaces/claude-cli/scripts/git-hooks/prepare-commit-msg
5+
6+
It is shipped *with the workspace image* (not as a Claude Code plugin).
7+
The entrypoint composes it into the runtime git hooks directory at
8+
container startup. When SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL are
9+
both set, the hook appends a `Co-authored-by:` trailer to commit messages.
10+
11+
These tests run real git commits inside the rebuilt workspace image and
12+
assert the hook's behavior end-to-end (entrypoint composition + hook
13+
script + git plumbing all working together).
14+
15+
Requirements:
16+
- Docker available
17+
- agentic-workspace-claude-cli:latest built locally
18+
19+
Run with: pytest tests/integration/test_workspace_operator_attribution.py -v
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import subprocess
25+
import textwrap
26+
27+
import pytest
28+
29+
WORKSPACE_IMAGE = "agentic-workspace-claude-cli:latest"
30+
OPERATOR_NAME = "TestOperator"
31+
OPERATOR_EMAIL = "operator@example.test"
32+
33+
34+
def docker_available() -> bool:
35+
try:
36+
result = subprocess.run(
37+
["docker", "image", "inspect", WORKSPACE_IMAGE],
38+
capture_output=True,
39+
check=False,
40+
)
41+
return result.returncode == 0
42+
except FileNotFoundError:
43+
return False
44+
45+
46+
pytestmark = [
47+
pytest.mark.integration,
48+
pytest.mark.skipif(
49+
not docker_available(),
50+
reason=f"Docker or {WORKSPACE_IMAGE} not available — run `just build-workspace-claude-cli`",
51+
),
52+
]
53+
54+
55+
def run_in_workspace(script: str, env: dict[str, str] | None = None) -> str:
56+
"""Run `script` (bash) inside a fresh workspace container, return stdout.
57+
58+
The default entrypoint runs first (so git hooks get composed); then
59+
`script` runs as the CMD argument. GIT_AUTHOR_* are always set so
60+
git commits succeed inside the container.
61+
"""
62+
env = env or {}
63+
cmd = [
64+
"docker", "run", "--rm",
65+
"-e", "GIT_AUTHOR_NAME=workspace-agent",
66+
"-e", "GIT_AUTHOR_EMAIL=agent@workspace.test",
67+
]
68+
for key, value in env.items():
69+
cmd.extend(["-e", f"{key}={value}"])
70+
cmd.extend([WORKSPACE_IMAGE, "bash", "-c", script])
71+
72+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
73+
if result.returncode != 0:
74+
pytest.fail(
75+
f"Command failed (exit {result.returncode}):\n"
76+
f"--- stdout ---\n{result.stdout}\n"
77+
f"--- stderr ---\n{result.stderr}"
78+
)
79+
return result.stdout
80+
81+
82+
def commit_and_read_message(extra_setup: str = "", env: dict[str, str] | None = None) -> str:
83+
"""Init a repo, commit, return the commit message body."""
84+
script = textwrap.dedent(f"""
85+
set -e
86+
cd /tmp && rm -rf repo && git init -q repo && cd repo
87+
echo file > file
88+
git add file
89+
{extra_setup}
90+
git commit -q -m "test commit"
91+
git log -1 --format=%B
92+
""")
93+
return run_in_workspace(script, env=env)
94+
95+
96+
class TestOperatorAttribution:
97+
"""End-to-end tests for the prepare-commit-msg hook."""
98+
99+
def test_no_env_vars_leaves_message_untouched(self) -> None:
100+
"""Without SYN_OPERATOR_* set, the hook should be a no-op."""
101+
msg = commit_and_read_message()
102+
assert "Co-authored-by:" not in msg, f"unexpected trailer in:\n{msg}"
103+
104+
def test_with_operator_env_appends_trailer(self) -> None:
105+
"""Both env vars set → a single Co-authored-by trailer is appended."""
106+
msg = commit_and_read_message(
107+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
108+
)
109+
expected = f"Co-authored-by: {OPERATOR_NAME} <{OPERATOR_EMAIL}>"
110+
assert expected in msg, f"missing trailer in:\n{msg}"
111+
assert msg.count("Co-authored-by:") == 1
112+
113+
def test_only_name_set_is_no_op(self) -> None:
114+
"""Either env var alone → no trailer (both are required)."""
115+
msg = commit_and_read_message(env={"SYN_OPERATOR_NAME": OPERATOR_NAME})
116+
assert "Co-authored-by:" not in msg
117+
118+
def test_idempotent_when_trailer_already_present(self) -> None:
119+
"""If the user's commit message already has the same trailer, don't double-append."""
120+
# Use -m with newlines via printf so the trailer is in the message from the start
121+
existing = f"Co-authored-by: {OPERATOR_NAME} <{OPERATOR_EMAIL}>"
122+
script = textwrap.dedent(f"""
123+
set -e
124+
cd /tmp && rm -rf repo && git init -q repo && cd repo
125+
echo file > file && git add file
126+
git commit -q -m "test commit" -m "{existing}"
127+
git log -1 --format=%B
128+
""")
129+
msg = run_in_workspace(
130+
script,
131+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
132+
)
133+
assert msg.count("Co-authored-by:") == 1, f"trailer duplicated:\n{msg}"
134+
135+
def test_template_source_skipped(self) -> None:
136+
"""Direct hook invocation with COMMIT_SOURCE=template must leave the message unchanged."""
137+
script = textwrap.dedent("""
138+
set -e
139+
HOOK=/opt/agentic/git-hooks/prepare-commit-msg
140+
test -x "$HOOK" || { echo "HOOK_MISSING"; exit 1; }
141+
echo "from-template" > /tmp/msg
142+
"$HOOK" /tmp/msg template
143+
cat /tmp/msg
144+
""")
145+
out = run_in_workspace(
146+
script,
147+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
148+
)
149+
assert "Co-authored-by:" not in out, f"trailer should be skipped on template source:\n{out}"
150+
151+
def test_merge_source_skipped(self) -> None:
152+
"""Direct hook invocation with COMMIT_SOURCE=merge must leave the message unchanged."""
153+
script = textwrap.dedent("""
154+
set -e
155+
HOOK=/opt/agentic/git-hooks/prepare-commit-msg
156+
echo "merge msg" > /tmp/msg
157+
"$HOOK" /tmp/msg merge
158+
cat /tmp/msg
159+
""")
160+
out = run_in_workspace(
161+
script,
162+
env={"SYN_OPERATOR_NAME": OPERATOR_NAME, "SYN_OPERATOR_EMAIL": OPERATOR_EMAIL},
163+
)
164+
assert "Co-authored-by:" not in out
165+
166+
def test_amend_commit_does_get_trailer(self) -> None:
167+
"""`git commit --amend` (COMMIT_SOURCE=commit) should still get the trailer if missing.
168+
169+
This guards the explicit decision NOT to skip COMMIT_SOURCE=commit.
170+
"""
171+
script = textwrap.dedent("""
172+
set -e
173+
cd /tmp && rm -rf repo && git init -q repo && cd repo
174+
echo file > file && git add file
175+
# First commit WITHOUT operator env to produce a trailer-less HEAD
176+
unset SYN_OPERATOR_NAME SYN_OPERATOR_EMAIL
177+
git commit -q -m "initial"
178+
# Now amend WITH operator env — the hook should add the trailer
179+
export SYN_OPERATOR_NAME="$NAME" SYN_OPERATOR_EMAIL="$EMAIL"
180+
git commit -q --amend --no-edit
181+
git log -1 --format=%B
182+
""")
183+
msg = run_in_workspace(
184+
script,
185+
env={"NAME": OPERATOR_NAME, "EMAIL": OPERATOR_EMAIL},
186+
)
187+
assert f"Co-authored-by: {OPERATOR_NAME} <{OPERATOR_EMAIL}>" in msg, (
188+
f"amend should pick up trailer when missing:\n{msg}"
189+
)
190+
191+
def test_newline_in_env_var_does_not_inject_extra_trailer(self) -> None:
192+
"""A newline in SYN_OPERATOR_NAME must not split into two trailer lines."""
193+
# Pass a literal newline via $'...' inside the docker -c shell
194+
script = textwrap.dedent("""
195+
set -e
196+
cd /tmp && rm -rf repo && git init -q repo && cd repo
197+
echo file > file && git add file
198+
export SYN_OPERATOR_NAME=$'evil\\nCo-authored-by: attacker <bad@bad.test>'
199+
export SYN_OPERATOR_EMAIL='ne@example.com'
200+
git commit -q -m "newline test"
201+
git log -1 --format=%B
202+
""")
203+
msg = run_in_workspace(script)
204+
trailer_lines = [line for line in msg.splitlines() if line.startswith("Co-authored-by:")]
205+
assert len(trailer_lines) == 1, (
206+
f"newline injection should be sanitized to a single trailer line; got {len(trailer_lines)}:\n{msg}"
207+
)
208+
# The single trailer's email field (last <...> on the line) must be the
209+
# operator email, not the attacker's. The attacker substring may still
210+
# appear inside the (now malformed) name field — that's cosmetic; the
211+
# security goal is that no separate trailer line was injected.
212+
assert trailer_lines[0].rstrip().endswith("<ne@example.com>"), (
213+
f"trailer's email field should be the operator email, got:\n{trailer_lines[0]}"
214+
)

plugins/git/.claude-plugin/plugin.json

Lines changed: 0 additions & 19 deletions
This file was deleted.

plugins/git/README.md

Lines changed: 0 additions & 22 deletions
This file was deleted.

providers/workspaces/claude-cli/Dockerfile

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,21 @@ RUN mkdir -p /workspace/artifacts/input \
171171
# NOTE: ~/.claude/settings.json is created by entrypoint.sh at runtime
172172
# because /home/agent is a tmpfs mount that wipes anything baked into the image.
173173

174-
# Copy entrypoint script
174+
# Copy entrypoint script and workspace-shipped git hooks.
175+
# git-hooks/ holds non-observability git hooks owned by the workspace itself
176+
# (e.g. prepare-commit-msg for operator Co-authored-by attribution). These
177+
# are NOT Claude Code plugins — the entrypoint installs them into the
178+
# runtime git hooks directory alongside hooks contributed by plugins.
175179
COPY scripts/entrypoint.sh /opt/agentic/entrypoint.sh
180+
COPY scripts/git-hooks/ /opt/agentic/git-hooks/
176181

177182
# Copy pre-staged plugins from build context (ADR-033)
178183
# Each plugin is a self-contained directory with .claude-plugin/plugin.json
179184
# and hooks/hooks.json using ${CLAUDE_PLUGIN_ROOT} for path resolution.
180185
COPY plugins/ /opt/agentic/plugins/
181186

182-
# Set permissions on plugins and entrypoint
183-
RUN chmod -R 755 /opt/agentic/plugins \
187+
# Set permissions on plugins, entrypoint, and workspace-shipped hooks
188+
RUN chmod -R 755 /opt/agentic/plugins /opt/agentic/git-hooks \
184189
&& find /opt/agentic/plugins -name "*.py" -exec chmod 755 {} \; \
185190
&& chmod 755 /opt/agentic/entrypoint.sh \
186191
&& chown -R agent:agent /opt/agentic

providers/workspaces/claude-cli/manifest.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ plugins:
3636
- sdlc
3737
- workspace
3838
- observability
39-
- git
4039
# Install location in the image
4140
install_path: /opt/agentic/plugins
4241

providers/workspaces/claude-cli/scripts/entrypoint.sh

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
# GIT_AUTHOR_NAME - Git commit author name (required for git ops)
1919
# GIT_AUTHOR_EMAIL - Git commit author email (required for git ops)
2020
# GITHUB_TOKEN - GitHub token for git push (optional)
21+
# SYN_OPERATOR_NAME - Operator display name for Co-authored-by trailer (optional)
22+
# SYN_OPERATOR_EMAIL - Operator email matching a verified GitHub account (optional)
2123
#
2224
# This script is the SINGLE SOURCE OF TRUTH for workspace configuration.
2325
# Orchestrators should NOT have hardcoded setup scripts.
@@ -107,28 +109,31 @@ fi
107109
# EVERY repo cloned or initialized inside this container — including repos
108110
# cloned by the agent mid-task.
109111
#
110-
# Multiple plugins can contribute git hooks. We compose them into a single
111-
# runtime directory ($GIT_HOOKS_DIR) and point core.hooksPath at it:
112-
# - observability/hooks/git/ → event-emission (post-commit, pre-push, …)
113-
# - git/hooks/ → other git concerns (prepare-commit-msg, …)
112+
# Two contributing sources, composed into a single runtime directory:
113+
# 1. /opt/agentic/git-hooks/ — workspace-shipped
114+
# hooks. Owned by the claude-cli provider itself (this dir is baked in
115+
# by the Dockerfile from providers/workspaces/claude-cli/scripts/git-hooks/).
116+
# Currently: prepare-commit-msg for operator Co-authored-by attribution
117+
# (driven by SYN_OPERATOR_NAME / SYN_OPERATOR_EMAIL env vars; no-op when
118+
# either is unset).
119+
# 2. /opt/agentic/plugins/observability/hooks/git/ — event-emission hooks
120+
# from the observability plugin (post-commit, pre-push, post-merge,
121+
# post-rewrite, post-checkout). These emit JSONL to stderr; the docker
122+
# exec stream in AgenticEventStreamAdapter merges stderr→stdout, and
123+
# WorkflowExecutionEngine stores the events in TimescaleDB.
114124
#
115-
# Each plugin owns hook filenames it ships; collisions across plugins are not
116-
# expected today. If two plugins ever ship the same hook name, the second
117-
# symlink wins.
118-
#
119-
# Observability hooks emit JSONL events to stderr; the docker exec stream in
120-
# AgenticEventStreamAdapter uses stderr=STDOUT to capture them alongside
121-
# Claude's stdout, and WorkflowExecutionEngine stores them in TimescaleDB.
125+
# Workspace-shipped hooks are linked first so any future name collision
126+
# resolves in favor of the observability event emitters (rare, but explicit).
122127
GIT_HOOKS_DIR="${HOME}/.git-hooks"
123128
mkdir -p "${GIT_HOOKS_DIR}"
124129

125-
PLUGINS_BASE="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}"
126-
for src_dir in "${PLUGINS_BASE}/observability/hooks/git" "${PLUGINS_BASE}/git/hooks"; do
130+
for src_dir in \
131+
/opt/agentic/git-hooks \
132+
"${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/observability/hooks/git"; do
127133
[ -d "${src_dir}" ] || continue
128134
for src in "${src_dir}"/*; do
129135
[ -f "${src}" ] || continue
130136
name=$(basename "${src}")
131-
# Skip non-hook files (installer, docs)
132137
case "${name}" in install.py|*.md|*.txt) continue ;; esac
133138
ln -sf "${src}" "${GIT_HOOKS_DIR}/${name}"
134139
done

plugins/git/hooks/prepare-commit-msg renamed to providers/workspaces/claude-cli/scripts/git-hooks/prepare-commit-msg

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,29 @@
11
#!/bin/sh
22
# prepare-commit-msg — append operator Co-authored-by trailer.
3-
# Plugin marker for install.py uninstall detection: agentic_events
43
#
54
# When the workspace is run by an operator (e.g. via Syntropic137 selfhost),
65
# SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL identify the human who sponsored
76
# the workspace. This hook adds them as a co-author on every agent commit so
87
# the operator gets attribution on PRs the workspace opens.
98
#
9+
# This hook is shipped as part of the claude-cli workspace provider (it is
10+
# NOT a Claude Code plugin). The entrypoint installs it into the runtime
11+
# git hooks directory at container start.
12+
#
1013
# Behavior:
1114
# - No-op when either env var is unset (backward compatible).
12-
# - Skipped on auto-generated message sources (merge/squash/template/commit)
13-
# so we don't pollute them.
15+
# - Skipped on auto-generated message sources (merge / squash / template)
16+
# so we don't pollute them. NOT skipped on `commit` source so
17+
# `--amend`, cherry-pick, and rebase commits also get the trailer if
18+
# they're missing it; idempotency below handles re-adds.
1419
# - Idempotent — won't append if the same trailer already exists.
1520
# - Always exits 0 — never blocks a commit on attribution failure.
1621

1722
COMMIT_MSG_FILE="$1"
1823
COMMIT_SOURCE="$2"
1924

20-
# Skip auto-generated message sources so we don't pollute them.
21-
# merge — message produced by `git merge`
22-
# squash — message produced by `git merge --squash`
23-
# template — user has a commit.template configured
24-
# commit — reusing an existing commit (e.g. `git commit --amend`,
25-
# `git cherry-pick`). The original message already has any
26-
# trailer it should have; idempotency below handles re-adds.
2725
case "$COMMIT_SOURCE" in
28-
merge|squash|template|commit) exit 0 ;;
26+
merge|squash|template) exit 0 ;;
2927
esac
3028

3129
if [ -z "$SYN_OPERATOR_NAME" ] || [ -z "$SYN_OPERATOR_EMAIL" ]; then

0 commit comments

Comments
 (0)