Skip to content

Commit 701b274

Browse files
authored
Merge pull request #2789 from jaylfc/exec/tsk-xa76qz
agent versioning: allowlist the state paths instead of denylisting secrets (tsk-xa76qz)
2 parents 3343d7d + aea2ef5 commit 701b274

11 files changed

Lines changed: 847 additions & 263 deletions
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
### Fixed
2-
- `InvalidRemoteError` is now caught in all agent version routes, returning 400 instead of 500 for malformed remotes.
2+
- `InvalidContainerTargetError` (malformed agent name or remote) is now caught in all agent version routes, returning 400 instead of 500.
33
- Agent state revert now uses dedicated `DirtyTreeError` and `NotAncestorError` exceptions instead of string matching.
44
- `git rev-parse HEAD` return code is now checked in `git_revert`.
55
- Agent version routes now enforce owner-or-admin authorization on list, diff, and revert operations.
66
- Cross-process lock serializes state writers (committer and revert) to prevent lost commits.
77
- Committer startup failures are now reported as `committer_failed` steps.
8-
- `.aws/`, `credentials`, and `*.credentials` patterns added to agent state `.gitignore`.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
### Security
2+
- Agent state versioning now versions an explicit allowlist of state paths (workspace, memory, per-framework AGENTS.md) instead of denying a list of secret patterns, so framework config carrying API keys and bridge tokens (`.hermes/config.yaml`, `.openclaw/env`), shell history, credential files and cache trees can no longer enter the agent's git history.
3+
4+
### Fixed
5+
- Agent state revert decides "noop" versus "reverted" inside the state lock, so a commit landing between resolving the requested version and the reset can no longer make the revert a silent no-op.
6+
- Unknown revisions are reported as 404 whatever wording the installed git uses ("bad revision", "unknown revision", "ambiguous argument", "bad object") instead of 409 container_unreachable.
7+
- A deployment whose auto-committer never starts now reports `versioning: false` with the reason, instead of claiming versioning is on while no commits will ever happen.
8+
- The auto-committer now computes its changed-file summary from the staged index after `git add -A`, so a new untracked file (the common agent change) is named in the commit subject instead of falling back to a bare "auto-commit".

changelog.d/tsk-yn5gze-agent-versions-fixes.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
- Agent state version revert now restores the full snapshot at the target commit instead of inverting a single commit. `git_revert` runs `git revert --no-edit <sha>..HEAD` so the tree matches the requested commit's state, and the revert endpoint asserts `README.md` remains with `notes.txt` absent after the operation.
44
- `.taos/trace/` is now excluded from the agent state gitignore before the initial commit, preventing trace directory contents from being staged into git history.
55
- Remote agent container targets are persisted in the agent record and used for all version operations, so remote-deployed agents resolve to `<remote>:taos-agent-{name}` instead of the unqualified local name.
6-
- The `sha` path parameter on version diff and revert routes is validated against `^[0-9a-f]{4,40}$` before it reaches any git argv, preventing argument injection such as `--output=.bashrc`.
6+
- The `sha` path parameter on version diff and revert routes is validated against `^[0-9a-fA-F]{7,40}$` (hex is case-insensitive) before it reaches any git argv, preventing argument injection such as `--output=.bashrc`.

tests/test_agent_committer.py

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,53 @@
22
from __future__ import annotations
33

44
import importlib.util
5-
import os
65
import subprocess
76
import sys
7+
from pathlib import Path
88

99
import pytest
1010

1111

12-
_COMMITTER_PATH = (
13-
os.path.dirname(__file__).replace("tests", "tinyagentos") + "/scripts/agent_committer.py"
12+
_COMMITTER_PATH = str(
13+
Path(__file__).resolve().parent.parent
14+
/ "tinyagentos"
15+
/ "scripts"
16+
/ "agent_committer.py"
1417
)
1518

1619

17-
def _load_committer(repo_path: str, interval: int = 1):
20+
def _load_committer(repo_path, tmp_path, interval: int = 1):
1821
spec = importlib.util.spec_from_file_location("agent_committer", _COMMITTER_PATH)
1922
mod = importlib.util.module_from_spec(spec)
2023
spec.loader.exec_module(mod)
21-
mod.REPO_PATH = repo_path
24+
mod.REPO_PATH = str(repo_path)
2225
mod.INTERVAL = interval
26+
# The lock file must live outside the repo under test: inside it, it
27+
# would show up as an untracked file and would either get committed by
28+
# `git add -A` or keep the tree permanently "dirty" for `_is_dirty()`.
29+
mod._STATE_LOCK_PATH = str(tmp_path / "agent_state.lock")
2330
return mod
2431

2532

26-
def _init_repo(tmp_path):
27-
subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True)
28-
subprocess.run(["git", "config", "user.email", "test@test"], cwd=tmp_path, check=True, capture_output=True)
29-
subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True, capture_output=True)
33+
def _init_repo(repo_path: Path):
34+
subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True, capture_output=True)
35+
subprocess.run(["git", "config", "user.email", "test@test"], cwd=repo_path, check=True, capture_output=True)
36+
subprocess.run(["git", "config", "user.name", "test"], cwd=repo_path, check=True, capture_output=True)
3037

3138

3239
class TestAgentCommitter:
3340
def test_commit_creates_commit_for_new_file(self, tmp_path):
34-
_init_repo(tmp_path)
35-
(tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n")
36-
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
41+
repo = tmp_path / "repo"
42+
repo.mkdir()
43+
_init_repo(repo)
44+
(repo / ".gitignore").write_text("*.secret\n.env\n*token*\n")
45+
subprocess.run(["git", "add", ".gitignore"], cwd=repo, check=True, capture_output=True)
3746
subprocess.run(
38-
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
47+
["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True
3948
)
4049

41-
committer = _load_committer(str(tmp_path))
42-
(tmp_path / "hello.txt").write_text("hello")
50+
committer = _load_committer(repo, tmp_path)
51+
(repo / "hello.txt").write_text("hello")
4352
committer._commit()
4453

4554
rc, out, _ = committer._git("log", "--oneline")
@@ -49,31 +58,35 @@ def test_commit_creates_commit_for_new_file(self, tmp_path):
4958
assert "hello.txt" in stat
5059

5160
def test_gitignored_secret_not_committed(self, tmp_path):
52-
_init_repo(tmp_path)
53-
(tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n")
54-
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
61+
repo = tmp_path / "repo"
62+
repo.mkdir()
63+
_init_repo(repo)
64+
(repo / ".gitignore").write_text("*.secret\n.env\n*token*\n")
65+
subprocess.run(["git", "add", ".gitignore"], cwd=repo, check=True, capture_output=True)
5566
subprocess.run(
56-
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
67+
["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True
5768
)
5869

59-
committer = _load_committer(str(tmp_path))
60-
(tmp_path / ".env").write_text("SECRET=abc")
61-
(tmp_path / "token.rsa").write_text("key")
70+
committer = _load_committer(repo, tmp_path)
71+
(repo / ".env").write_text("SECRET=abc")
72+
(repo / "token.rsa").write_text("key")
6273
committer._commit()
6374

6475
_, log_out, _ = committer._git("log", "--all", "--stat")
6576
assert ".env" not in log_out
6677
assert "token.rsa" not in log_out
6778

6879
def test_no_commit_when_clean(self, tmp_path):
69-
_init_repo(tmp_path)
70-
(tmp_path / ".gitignore").write_text("")
71-
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
80+
repo = tmp_path / "repo"
81+
repo.mkdir()
82+
_init_repo(repo)
83+
(repo / ".gitignore").write_text("")
84+
subprocess.run(["git", "add", ".gitignore"], cwd=repo, check=True, capture_output=True)
7285
subprocess.run(
73-
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
86+
["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True
7487
)
7588

76-
committer = _load_committer(str(tmp_path))
89+
committer = _load_committer(repo, tmp_path)
7790
committer._commit()
7891

7992
rc, out, _ = committer._git("log", "--oneline")
@@ -83,18 +96,47 @@ def test_no_commit_when_clean(self, tmp_path):
8396
assert lines[0].endswith("initial")
8497

8598
def test_gitignored_ssh_key_not_committed(self, tmp_path):
86-
_init_repo(tmp_path)
87-
(tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n.ssh/\n")
88-
subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True)
99+
repo = tmp_path / "repo"
100+
repo.mkdir()
101+
_init_repo(repo)
102+
(repo / ".gitignore").write_text("*.secret\n.env\n*token*\n.ssh/\n")
103+
subprocess.run(["git", "add", ".gitignore"], cwd=repo, check=True, capture_output=True)
89104
subprocess.run(
90-
["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True
105+
["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True
91106
)
92107

93-
committer = _load_committer(str(tmp_path))
94-
(tmp_path / ".ssh").mkdir()
95-
(tmp_path / ".ssh" / "id_rsa").write_text("fake-key")
108+
committer = _load_committer(repo, tmp_path)
109+
(repo / ".ssh").mkdir()
110+
(repo / ".ssh" / "id_rsa").write_text("fake-key")
96111
committer._commit()
97112

98113
_, log_out, _ = committer._git("log", "--all", "--stat")
99114
assert ".ssh" not in log_out
100115
assert "id_rsa" not in log_out
116+
117+
def test_commit_message_names_a_new_untracked_file(self, tmp_path):
118+
"""`_changed_summary` must be computed from what `git add -A` staged,
119+
not from what was staged before it ran: an untracked file (the
120+
common agent change — a new workspace/ file) shows up in neither
121+
`git diff --cached --name-only` nor `git diff --name-only` before
122+
staging, so a pre-add summary always falls back to "auto-commit" and
123+
the commit message loses the file name for exactly this case."""
124+
repo = tmp_path / "repo"
125+
repo.mkdir()
126+
_init_repo(repo)
127+
(repo / ".gitignore").write_text("")
128+
subprocess.run(["git", "add", ".gitignore"], cwd=repo, check=True, capture_output=True)
129+
subprocess.run(
130+
["git", "commit", "-m", "initial"], cwd=repo, check=True, capture_output=True
131+
)
132+
133+
committer = _load_committer(repo, tmp_path)
134+
(repo / "workspace_notes.md").write_text("new untracked file")
135+
committer._commit()
136+
137+
rc, out, _ = committer._git("log", "-1", "--format=%s")
138+
assert rc == 0
139+
subject = out.strip()
140+
assert "workspace_notes.md" in subject, (
141+
f"commit subject does not name the new file: {subject!r}"
142+
)

0 commit comments

Comments
 (0)