Skip to content

Commit 4848889

Browse files
fix(install): the Kiro agent files are copies -- kiro-cli's rewrite stays out of the repo
kiro-cli rewrites an agent file it loads: the legacy `hooks` object becomes the array form (`name`/`trigger`/`action`/`timeout`) and the `toolsSettings` denylist regexes become `permissions.rules` globs. The installer placed `~/.kiro/agents/{study-mentor,studyloop,study-plan-architect}.json` as links into the repository, so on 2026-10-12 that rewrite landed in agents/kiro/studyloop.json and study-plan-architect.json -- both at 15:09:30, seven manifest/agent-contract tests red, and the migrated denylist carrying a bare `*` (every shell command denied) where `curl .* \| bash`, `curl .* \| sh` and `wget .* \| bash` had been. Nobody edited those files; the doctor already documents the migration (doctor/harness.py, _kiro_stop_commands). LinkSpec gains `copy`; the three agent FILES set it, the directories beside them (shared/, study-mentor/, study-plan-architect/, skills) stay links so their file:// resources resolve exactly as before. _copy_path owns the copy by agent NAME: a target whose `name` matches the source is StudyLoop's whatever kiro-cli has since written into it, and is refreshed in place with no .bak (the migration is the reason the file is a copy at all); a legacy link is replaced; a foreign file at our path is moved to .bak on install and left alone on uninstall; an identical copy is a no-op. Red first: nine new tests in test_kiro_studyloop_agent.py (every agent file a copy; a kiro-cli-shaped rewrite of the copy leaves agents/kiro/ byte-identical; reinstall refreshes a rewritten copy and is a no-op on an identical one; legacy link replaced; foreign file backed up; uninstall removes ours after a rewrite, leaves a foreign file) all failed against the link installer, and the architect contract test now pins the copy; 63/63 green with the change. Blast radius: 67 unit files touching installers, doctor, adapters and the CLI install path -- 1850 passed, the one failure the known sandbox-only tmux shim test (identical at HEAD). mkdocs --strict clean. The adapter's own swap (adapters/kiro.py) is unaffected: it reads its template from the repository, backs up whatever sits at the target and restores only over its own swap; a copy there is simply a regular file it never mistakes for the swap. Existing installs keep their links until `studyloop install agents --tool kiro` is re-run, which replaces them.
1 parent 9550246 commit 4848889

5 files changed

Lines changed: 253 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,21 @@ experience may change before `1.0.0`.
233233
(`element is not visible`; deterministic locally in isolation). The helper
234234
now writes the matching `session-state.json` first, as the reload test
235235
already does, so the server reports the session the mount pretends to have.
236+
- The three Kiro agent files are installed as copies, not links. kiro-cli
237+
rewrites an agent file it loads -- the legacy `hooks` object becomes the
238+
array form, the `toolsSettings` denylist regexes become `permissions.rules`
239+
globs -- and through the installer's links that rewrite landed in the
240+
repository's `agents/kiro/studyloop.json` and `study-plan-architect.json`
241+
on 2026-10-12, both in one second: seven manifest tests red, and the migrated
242+
denylist carried a bare `*` (every shell command denied) where
243+
`curl .* \| bash` had been. The copy keeps that rewrite on the learner's
244+
machine; a re-run of `studyloop install agents` refreshes a migrated copy from
245+
the shipped definition (no `.bak`, it is StudyLoop's own file by name), still
246+
backs up a foreign file at the same path, and `--uninstall` removes only
247+
ours. The directories beside the files (`shared/`, `study-mentor/`,
248+
`study-plan-architect/`, skills) stay links, so every `file://` resource
249+
resolves as before. Run `studyloop install agents --tool kiro` once to replace
250+
an existing link.
236251
- The `voice-tts` OpenSpec specification described the architecture that was
237252
deleted, not the one that shipped: it required browser-side Kokoro via
238253
`transformers.js` and forbade any server-side synthesis for the web path,

‎docs/agent-install.md‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Then check the result:
4848
studyloop doctor --category agents
4949
```
5050

51-
The installer links StudyLoop-managed definitions while preserving an existing file as a `.bak` backup when necessary. Use `studyloop install agents --uninstall` to remove links created by StudyLoop.
51+
The installer links StudyLoop-managed definitions (copying the three Kiro agent files, see below) while preserving an existing file as a `.bak` backup when necessary. Use `studyloop install agents --uninstall` to remove what StudyLoop installed.
5252

5353
## Start a study session
5454

@@ -87,6 +87,15 @@ StudyLoop's `studyloop` and `session-db` MCP servers, and pre-approves no
8787
tools, the same grants ACP sessions had before it existed. You never start it
8888
yourself; `studyloop doctor` checks that kiro-cli validates and lists it.
8989

90+
The three Kiro agent files (`study-mentor.json`, `studyloop.json`,
91+
`study-plan-architect.json`) are installed as **copies** under `~/.kiro/agents/`,
92+
not links: kiro-cli rewrites an agent file it loads (its hook and permission
93+
schema migrate in place), and a link would carry that rewrite back into
94+
StudyLoop's own sources. If kiro-cli has migrated your copy, that is expected;
95+
`studyloop install agents --tool kiro` refreshes it from the shipped definition.
96+
The directories beside them (`shared/`, `study-mentor/`, `study-plan-architect/`,
97+
the skills) remain links, so their `file://` resources resolve exactly as before.
98+
9099
Kiro also receives the opt-in `studyloop-xtiles-wind-down` skill at `~/.kiro/skills/`, as a symlink to the shared copy described below. It stays silent unless your second-brain provider is `xtiles` and an `xtiles` MCP server is connected.
91100

92101
### Codex

‎packages/studyloop/src/studyloop/installers.py‎

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
import os
67
import shutil
78
import subprocess
@@ -36,21 +37,43 @@ class LinkSpec:
3637
and harnesses that already read ``~/.agents/skills`` (Codex, OpenCode, pi)
3738
would get redundant paths. With a hub, those three are served directly and
3839
only Kiro/Claude need native-directory links.
40+
41+
``copy=True`` installs a COPY of ``source`` at ``target`` instead of a link.
42+
For a file the harness itself rewrites that is the only safe shape: kiro-cli
43+
migrates an agent file it loads (the legacy ``hooks`` object to the array
44+
form, ``toolsSettings`` denylist regexes to ``permissions.rules`` globs) and
45+
writes the result back to the path it read -- through a link, into the
46+
repository. On 2026-10-12 that left ``agents/kiro/studyloop.json`` and
47+
``study-plan-architect.json`` rewritten in one second, seven manifest tests
48+
red, and a bare ``*`` (every shell command denied) in the migrated denylist
49+
where ``curl .* \\| bash`` had been. A copy keeps the rewrite where it
50+
belongs, on the learner's machine; re-running the installer refreshes it.
3951
"""
4052

4153
source: str
4254
target: str
55+
copy: bool = False
4356

4457

4558
_HOME = Path.home()
4659

4760
_TOOL_LINKS: dict[str, tuple[LinkSpec, ...]] = {
4861
"kiro": (
49-
LinkSpec("agents/kiro/study-mentor.json", str(_HOME / ".kiro/agents/study-mentor.json")),
62+
# The three agent FILES are copies (LinkSpec.copy): kiro-cli rewrites the
63+
# agent file it loads, and through a link that rewrite reached the
64+
# repository (2026-10-12). The directories beside them stay links --
65+
# kiro-cli reads those, it does not write them.
66+
LinkSpec(
67+
"agents/kiro/study-mentor.json",
68+
str(_HOME / ".kiro/agents/study-mentor.json"),
69+
copy=True,
70+
),
5071
# StudyLoop's own persona-neutral agent, named on every Kiro ACP launch
5172
# (`kiro-cli acp --agent studyloop`) so the learner's default agent never
5273
# configures a StudyLoop session (owner steer, 2026-09-26).
53-
LinkSpec("agents/kiro/studyloop.json", str(_HOME / ".kiro/agents/studyloop.json")),
74+
LinkSpec(
75+
"agents/kiro/studyloop.json", str(_HOME / ".kiro/agents/studyloop.json"), copy=True
76+
),
5477
# study-plan-architect.json declares `file://shared/*.md` resources, which
5578
# Kiro resolves relative to the agent file's directory; this link is what
5679
# makes them exist there (council phase 2, L7 reviewer).
@@ -59,6 +82,7 @@ class LinkSpec:
5982
LinkSpec(
6083
"agents/kiro/study-plan-architect.json",
6184
str(_HOME / ".kiro/agents/study-plan-architect.json"),
85+
copy=True,
6286
),
6387
LinkSpec(
6488
"agents/kiro/study-plan-architect",
@@ -1384,11 +1408,59 @@ def _points_to(target: Path, source: Path) -> bool:
13841408
return current.resolve() == source.resolve()
13851409

13861410

1411+
def _agent_name(path: Path) -> str | None:
1412+
"""The ``name`` of the agent definition at *path*, or None if it is not one."""
1413+
try:
1414+
data = json.loads(path.read_text(encoding="utf-8"))
1415+
except (OSError, ValueError):
1416+
return None
1417+
name = data.get("name") if isinstance(data, dict) else None
1418+
return name if isinstance(name, str) else None
1419+
1420+
1421+
def _copy_path(source: Path, target: Path, *, uninstall: bool) -> int:
1422+
"""Install ``source`` at ``target`` as a copy (``LinkSpec.copy``); 1 if anything changed.
1423+
1424+
The file is StudyLoop's by NAME: a target whose agent ``name`` matches the
1425+
source's is ours whatever its content now says -- a copy kiro-cli has since
1426+
migrated is refreshed in place with no backup, since the migration is the
1427+
reason the file is a copy at all (see :class:`LinkSpec`). A legacy link from
1428+
an earlier install is replaced. Anything else at the path belongs to the
1429+
learner: on install it is moved to ``<name>.bak`` first, on uninstall it is
1430+
left alone.
1431+
"""
1432+
ours = target.is_symlink() and _points_to(target, source)
1433+
if not target.is_symlink() and target.is_file():
1434+
ours = _agent_name(target) == _agent_name(source)
1435+
if uninstall:
1436+
if ours:
1437+
target.unlink()
1438+
return 1
1439+
return 0
1440+
if target.is_symlink():
1441+
target.unlink() # legacy layout, or a stale link: the copy replaces it either way
1442+
elif target.is_file():
1443+
if target.read_bytes() == source.read_bytes():
1444+
return 0
1445+
if not ours:
1446+
shutil.move(str(target), str(target.with_name(f"{target.name}.bak")))
1447+
elif target.exists():
1448+
shutil.move(str(target), str(target.with_name(f"{target.name}.bak")))
1449+
shutil.copyfile(source, target)
1450+
return 1
1451+
1452+
13871453
def _link_paths(repo_root: Path, specs: tuple[LinkSpec, ...], *, uninstall: bool) -> int:
13881454
changed = 0
13891455
for spec in specs:
13901456
source = _render_source(spec.source, repo_root)
13911457
target = _render_target(spec.target, repo_root)
1458+
if spec.copy:
1459+
if not uninstall and not source.exists():
1460+
raise InstallError(f"Missing install asset: {source}")
1461+
target.parent.mkdir(parents=True, exist_ok=True)
1462+
changed += _copy_path(source, target, uninstall=uninstall)
1463+
continue
13921464
# In-repo targets use relative links so they survive repo moves and
13931465
# syncing between machines with different absolute paths.
13941466
in_repo = "{repo_root}" in spec.target

‎packages/studyloop/tests/test_install_agent_contracts.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ def test_xtiles_skill_installed_for_each_detected_tool(tmp_path: Path, monkeypat
355355
"_TOOL_LINKS",
356356
{
357357
tool: tuple(
358-
installers.LinkSpec(spec.source, _rebase(spec.target, tmp_path)) for spec in specs
358+
installers.LinkSpec(spec.source, _rebase(spec.target, tmp_path), copy=spec.copy)
359+
for spec in specs
359360
)
360361
for tool, specs in installers._TOOL_LINKS.items()
361362
},
@@ -654,7 +655,8 @@ def test_installed_kiro_architect_resolves_its_prompt_and_servers(
654655
"_TOOL_LINKS",
655656
{
656657
tool: tuple(
657-
installers.LinkSpec(spec.source, _rebase(spec.target, tmp_path)) for spec in specs
658+
installers.LinkSpec(spec.source, _rebase(spec.target, tmp_path), copy=spec.copy)
659+
for spec in specs
658660
)
659661
for tool, specs in installers._TOOL_LINKS.items()
660662
},
@@ -707,7 +709,14 @@ def test_installed_kiro_architect_resolves_its_prompt_and_servers(
707709

708710
assert claude_path.is_symlink(), "claude study-plan-architect.md was not linked"
709711
assert opencode_path.is_symlink(), "opencode study-plan-architect.md was not linked"
710-
assert kiro_json.is_symlink(), "kiro study-plan-architect.json was not linked"
712+
# A copy, not a link (2026-10-12): kiro-cli rewrites the agent file it loads,
713+
# and through a link that rewrite reached agents/kiro/ in the repository.
714+
assert kiro_json.is_file() and not kiro_json.is_symlink(), (
715+
"kiro study-plan-architect.json was not installed as a copy"
716+
)
717+
assert (
718+
kiro_json.read_bytes() == (repo_root / "agents/kiro/study-plan-architect.json").read_bytes()
719+
)
711720
assert kiro_dir.is_symlink(), "kiro study-plan-architect/ directory was not linked"
712721
assert (kiro_dir / "persona.md").is_file()
713722

@@ -732,7 +741,8 @@ def _install_kiro_into_fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch
732741
"_TOOL_LINKS",
733742
{
734743
tool: tuple(
735-
installers.LinkSpec(spec.source, _rebase(spec.target, tmp_path)) for spec in specs
744+
installers.LinkSpec(spec.source, _rebase(spec.target, tmp_path), copy=spec.copy)
745+
for spec in specs
736746
)
737747
for tool, specs in installers._TOOL_LINKS.items()
738748
},

‎packages/studyloop/tests/test_kiro_studyloop_agent.py‎

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ def test_install_places_it(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
9898
monkeypatch.setattr(
9999
installers,
100100
"_TOOL_LINKS",
101-
{"kiro": tuple(installers.LinkSpec(spec.source, str(target)) for spec in specs)},
101+
{
102+
"kiro": tuple(
103+
installers.LinkSpec(spec.source, str(target), copy=spec.copy) for spec in specs
104+
)
105+
},
102106
)
103107
monkeypatch.setattr(installers, "_SHARED_LINKS", ())
104108
monkeypatch.setattr(installers, "XTILES_SKILL_LINKS", {})
@@ -109,8 +113,8 @@ def test_install_places_it(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
109113

110114
installers.install_agent_definitions(REPO_ROOT, tools=["kiro"])
111115

112-
assert target.is_symlink()
113-
assert target.resolve() == DEFINITION.resolve()
116+
assert target.is_file() and not target.is_symlink(), "installed as a copy, not a link"
117+
assert target.read_bytes() == DEFINITION.read_bytes()
114118
assert json.loads(target.read_text(encoding="utf-8"))["name"] == "studyloop"
115119

116120
def test_the_manifest_tracks_it(self) -> None:
@@ -125,6 +129,139 @@ def test_the_nightly_install_job_checks_it_landed(self) -> None:
125129
assert 'test -e "$HOME/.kiro/agents/studyloop.json"' in workflow
126130

127131

132+
_KIRO_AGENT_SOURCES = (
133+
"agents/kiro/study-mentor.json",
134+
"agents/kiro/studyloop.json",
135+
"agents/kiro/study-plan-architect.json",
136+
)
137+
138+
139+
class TestKiroAgentFilesAreCopiesNotLinks:
140+
"""kiro-cli rewrites an agent file it loads: the legacy ``hooks`` object becomes
141+
the array form, ``toolsSettings`` denylist regexes become ``permissions.rules``
142+
globs. Linked into the repository, that rewrite landed in ``agents/kiro/`` on
143+
2026-10-12 -- both files in one second, seven manifest tests red, and the
144+
migrated denylist carried a bare ``*`` (every shell command denied) where
145+
``curl .* \\| bash`` had been. The installed agent file is therefore a COPY
146+
the repository never sees again; the directories beside it (``shared/``,
147+
``study-plan-architect/``, skills) stay links, kiro-cli does not write those.
148+
"""
149+
150+
@staticmethod
151+
def _install(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, uninstall: bool = False):
152+
specs = [
153+
spec for spec in installers._TOOL_LINKS["kiro"] if spec.source in _KIRO_AGENT_SOURCES
154+
]
155+
assert len(specs) == 3, "all three Kiro agent files are in the install table"
156+
home = tmp_path / "home"
157+
rebased = tuple(
158+
installers.LinkSpec(
159+
spec.source,
160+
spec.target.replace(str(installers._HOME), str(home), 1),
161+
copy=spec.copy,
162+
)
163+
for spec in specs
164+
)
165+
monkeypatch.setattr(installers, "_TOOL_LINKS", {"kiro": rebased})
166+
monkeypatch.setattr(installers, "_SHARED_LINKS", ())
167+
monkeypatch.setattr(installers, "XTILES_SKILL_LINKS", {})
168+
monkeypatch.setattr(installers, "SESSION_MEMORY_SKILL_LINKS", {})
169+
monkeypatch.setattr(installers, "install_session_db_mandate", lambda *_a, **_k: {})
170+
monkeypatch.setattr(installers, "register_mcp_servers", lambda *_a, **_k: {})
171+
installers.install_agent_definitions(REPO_ROOT, tools=["kiro"], uninstall=uninstall)
172+
return home / ".kiro/agents"
173+
174+
@staticmethod
175+
def _kiro_cli_migration(path: Path) -> None:
176+
"""What kiro-cli 2.29 wrote through the link on 2026-10-12, in shape."""
177+
data = json.loads(path.read_text(encoding="utf-8"))
178+
hook = data["hooks"]["stop"][0]["command"]
179+
data["hooks"] = [
180+
{
181+
"name": "stop-0",
182+
"trigger": "stop",
183+
"action": {"type": "command", "command": hook},
184+
"timeout": 10,
185+
}
186+
]
187+
data["permissions"] = {
188+
"rules": [
189+
{"capability": "shell", "match": ["chmod 777*", "*", "dd if=*"], "effect": "deny"}
190+
]
191+
}
192+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
193+
194+
def test_every_kiro_agent_file_is_a_copy(self, tmp_path: Path, monkeypatch) -> None:
195+
agents = self._install(tmp_path, monkeypatch)
196+
for source in _KIRO_AGENT_SOURCES:
197+
installed = agents / Path(source).name
198+
assert installed.is_file() and not installed.is_symlink(), source
199+
assert installed.read_bytes() == (REPO_ROOT / source).read_bytes(), source
200+
201+
def test_a_kiro_cli_rewrite_of_the_copy_leaves_the_repository_untouched(
202+
self, tmp_path: Path, monkeypatch
203+
) -> None:
204+
before = {s: (REPO_ROOT / s).read_bytes() for s in _KIRO_AGENT_SOURCES}
205+
agents = self._install(tmp_path, monkeypatch)
206+
for source in _KIRO_AGENT_SOURCES:
207+
self._kiro_cli_migration(agents / Path(source).name)
208+
after = {s: (REPO_ROOT / s).read_bytes() for s in _KIRO_AGENT_SOURCES}
209+
assert after == before, "a rewrite of the installed file reached agents/kiro/"
210+
migrated = json.loads((agents / "studyloop.json").read_text(encoding="utf-8"))
211+
assert isinstance(migrated["hooks"], list), "the rewrite itself landed on the copy"
212+
213+
def test_reinstall_refreshes_a_rewritten_copy(self, tmp_path: Path, monkeypatch) -> None:
214+
agents = self._install(tmp_path, monkeypatch)
215+
self._kiro_cli_migration(agents / "studyloop.json")
216+
self._install(tmp_path, monkeypatch)
217+
refreshed = agents / "studyloop.json"
218+
assert refreshed.read_bytes() == DEFINITION.read_bytes()
219+
assert not (agents / "studyloop.json.bak").exists(), "our own file is not backed up"
220+
221+
def test_reinstall_is_a_no_op_on_an_identical_copy(self, tmp_path: Path, monkeypatch) -> None:
222+
agents = self._install(tmp_path, monkeypatch)
223+
stamp = (agents / "studyloop.json").stat().st_mtime_ns
224+
self._install(tmp_path, monkeypatch)
225+
assert (agents / "studyloop.json").stat().st_mtime_ns == stamp
226+
227+
def test_install_replaces_the_legacy_link_with_a_copy(
228+
self, tmp_path: Path, monkeypatch
229+
) -> None:
230+
agents = tmp_path / "home/.kiro/agents"
231+
agents.mkdir(parents=True)
232+
(agents / "studyloop.json").symlink_to(DEFINITION)
233+
self._install(tmp_path, monkeypatch)
234+
installed = agents / "studyloop.json"
235+
assert installed.is_file() and not installed.is_symlink()
236+
assert installed.read_bytes() == DEFINITION.read_bytes()
237+
238+
def test_install_backs_up_a_foreign_file_at_our_name(self, tmp_path: Path, monkeypatch) -> None:
239+
agents = tmp_path / "home/.kiro/agents"
240+
agents.mkdir(parents=True)
241+
foreign = json.dumps({"name": "my-own-agent", "prompt": "mine"})
242+
(agents / "studyloop.json").write_text(foreign, encoding="utf-8")
243+
self._install(tmp_path, monkeypatch)
244+
assert (agents / "studyloop.json").read_bytes() == DEFINITION.read_bytes()
245+
assert (agents / "studyloop.json.bak").read_text(encoding="utf-8") == foreign
246+
247+
def test_uninstall_removes_our_copy_even_after_a_rewrite(
248+
self, tmp_path: Path, monkeypatch
249+
) -> None:
250+
agents = self._install(tmp_path, monkeypatch)
251+
self._kiro_cli_migration(agents / "studyloop.json")
252+
self._install(tmp_path, monkeypatch, uninstall=True)
253+
for source in _KIRO_AGENT_SOURCES:
254+
assert not (agents / Path(source).name).exists(), source
255+
256+
def test_uninstall_leaves_a_foreign_file_alone(self, tmp_path: Path, monkeypatch) -> None:
257+
agents = tmp_path / "home/.kiro/agents"
258+
agents.mkdir(parents=True)
259+
foreign = json.dumps({"name": "my-own-agent"})
260+
(agents / "studyloop.json").write_text(foreign, encoding="utf-8")
261+
self._install(tmp_path, monkeypatch, uninstall=True)
262+
assert (agents / "studyloop.json").read_text(encoding="utf-8") == foreign
263+
264+
128265
class TestTheAcpLaunchNamesIt:
129266
@staticmethod
130267
def _argv(agent: str, monkeypatch: pytest.MonkeyPatch) -> list[str]:

0 commit comments

Comments
 (0)