Skip to content

Commit ac0de30

Browse files
committed
feat(decisions): install from URL (Phase B)
`ai-config-kit decisions install <https-url> [--sha256 HEX] [--force] [--dry-run]` fetches a gzip tarball, extracts it, validates the manifest, and applies the pack the same way bundled packs are applied. Hardening: - HTTPS-only (rejects http://, file://, anything else) - 5MB size cap on the download body - 30s socket timeout - Optional sha256 verified BEFORE extraction - Path-traversal guard: any tarball entry starting with `/` or containing `..` is refused before extract - tarfile.extractall(filter="data") (3.12 safety filter) - Tolerates "top-level dir wraps manifest" tarballs (common when GitHub serves a repo archive) The remote pack reuses `_build_pack` + the same secret-pattern preflight + per-file copy loop as `decisions_apply`. The CLI flow mirrors `apply`'s output (written / overwritten / skipped lists). Audit event `decisions_install` records (pack, url, sha256, force, counts). 5 new tests (269 total): - HTTP URL refused - dry-run returns immediately - end-to-end via mocked urlopen returning an in-memory tarball - sha256 mismatch raises before extract - path-traversal tarball entry rejected CLI: `decisions install URL [--sha256 HEX] [--force] [--dry-run]`. Library: `ClaudeConfig.decisions_install(url, sha256=None, force=False, dry_run=False, max_bytes=5MB, timeout=30s)`. Closes SPEC §4 Phase B.
1 parent 647d712 commit ac0de30

3 files changed

Lines changed: 348 additions & 0 deletions

File tree

src/ai_config_kit/cli.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,28 @@ def _build_parser() -> argparse.ArgumentParser:
346346
)
347347
dec_sub = p_dec.add_subparsers(dest="decisions_cmd", required=True, metavar="ACTION")
348348
dec_sub.add_parser("list", help="Show all bundled packs.")
349+
350+
p_dec_install = dec_sub.add_parser(
351+
"install",
352+
help="Fetch + apply a decision pack from an HTTPS URL (.tar.gz).",
353+
)
354+
p_dec_install.add_argument("url", type=str, help="HTTPS URL of a .tar.gz pack.")
355+
p_dec_install.add_argument(
356+
"--sha256",
357+
type=str,
358+
default=None,
359+
help="Hex-encoded sha256 of the tarball. Verified before extraction.",
360+
)
361+
p_dec_install.add_argument(
362+
"--force",
363+
action="store_true",
364+
help="Overwrite existing files in the content dir.",
365+
)
366+
p_dec_install.add_argument(
367+
"--dry-run",
368+
action="store_true",
369+
help="Print what would happen; do not fetch.",
370+
)
349371
p_dec_show = dec_sub.add_parser("show", help="Print a pack's manifest + README.")
350372
p_dec_show.add_argument("name")
351373
p_dec_apply = dec_sub.add_parser(
@@ -724,6 +746,29 @@ def main(argv: list[str] | None = None) -> int:
724746
print(f" = {f}")
725747
return 0
726748

749+
if args.decisions_cmd == "install":
750+
inst_report = cfg.decisions_install(
751+
args.url,
752+
sha256=args.sha256,
753+
force=args.force,
754+
dry_run=args.dry_run,
755+
)
756+
print(inst_report.summary())
757+
if not args.quiet and not inst_report.dry_run:
758+
if inst_report.written:
759+
print(" written:")
760+
for f in inst_report.written:
761+
print(f" + {f}")
762+
if inst_report.overwritten:
763+
print(" overwritten:")
764+
for f in inst_report.overwritten:
765+
print(f" ~ {f}")
766+
if inst_report.skipped:
767+
print(" skipped (already present; --force to overwrite):")
768+
for f in inst_report.skipped:
769+
print(f" = {f}")
770+
return 0
771+
727772
if args.cmd == "reconcile":
728773
recon_report = cfg.reconcile(force_reapply=args.force)
729774
print(recon_report.summary())

src/ai_config_kit/manager.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2864,6 +2864,159 @@ def decisions_list(self) -> DecisionsListReport:
28642864
packs.append(self._build_pack(data, entry))
28652865
return DecisionsListReport(packs=packs)
28662866

2867+
# ---- remote pack installation (Phase B) ---------------------------
2868+
2869+
def decisions_install(
2870+
self,
2871+
url: str,
2872+
sha256: str | None = None,
2873+
force: bool = False,
2874+
dry_run: bool = False,
2875+
max_bytes: int = 5 * 1024 * 1024,
2876+
timeout: float = 30.0,
2877+
) -> DecisionsApplyReport:
2878+
"""Fetch a decision pack from an HTTPS URL and apply it.
2879+
2880+
The URL must point at a gzip-compressed tarball whose root
2881+
contains a ``manifest.json`` in the standard pack schema. The
2882+
tarball is downloaded into a temp dir (size + tls-validated)
2883+
and extracted; the extracted tree is then treated as a one-off
2884+
pack-root and passed through the same ``decisions_apply``
2885+
path as a bundled pack.
2886+
2887+
@param url HTTPS URL of the tarball.
2888+
@param sha256 optional hex digest. If given, the download is
2889+
verified before extraction.
2890+
@param force overwrite existing files in src_dir (default no).
2891+
@param dry_run print what would happen; don't extract or write.
2892+
@param max_bytes hard cap on response body (default 5MB).
2893+
@param timeout socket timeout (default 30s).
2894+
@return DecisionsApplyReport — same shape as a local-pack apply.
2895+
@raises ConfigError on http/ssl/integrity/manifest failures.
2896+
"""
2897+
from urllib.parse import urlparse
2898+
2899+
parsed = urlparse(url)
2900+
if parsed.scheme != "https":
2901+
raise ConfigError(f"decisions install URL must be https://, got {parsed.scheme}://")
2902+
if not parsed.netloc:
2903+
raise ConfigError(f"decisions install URL has no host: {url!r}")
2904+
2905+
if dry_run:
2906+
return DecisionsApplyReport(pack=url, dry_run=True)
2907+
2908+
import io
2909+
import tarfile
2910+
import tempfile
2911+
import urllib.request
2912+
2913+
# Fetch
2914+
req = urllib.request.Request(url, headers={"User-Agent": "ai-config-kit"})
2915+
try:
2916+
with urllib.request.urlopen(req, timeout=timeout) as resp:
2917+
buf = resp.read(max_bytes + 1)
2918+
except (OSError, ValueError) as e:
2919+
raise ConfigError(f"decisions install: fetch failed: {e}") from e
2920+
if len(buf) > max_bytes:
2921+
raise ConfigError(
2922+
f"decisions install: response exceeds {max_bytes} bytes"
2923+
)
2924+
2925+
# SHA verify
2926+
if sha256:
2927+
got = hashlib.sha256(buf).hexdigest()
2928+
if got.lower() != sha256.lower():
2929+
raise ConfigError(
2930+
f"decisions install: sha256 mismatch (expected {sha256}, got {got})"
2931+
)
2932+
2933+
# Extract to a temp dir; validate manifest at root.
2934+
with tempfile.TemporaryDirectory(prefix="ai-config-kit-pack-") as tmp:
2935+
tmp_root = Path(tmp)
2936+
try:
2937+
with tarfile.open(fileobj=io.BytesIO(buf), mode="r:gz") as tar:
2938+
# Refuse paths that escape the extraction root.
2939+
for member in tar.getmembers():
2940+
if member.name.startswith("/") or ".." in Path(member.name).parts:
2941+
raise ConfigError(
2942+
f"decisions install: tarball entry escapes root: {member.name!r}"
2943+
)
2944+
tar.extractall(tmp_root, filter="data")
2945+
except tarfile.TarError as e:
2946+
raise ConfigError(f"decisions install: tar extract failed: {e}") from e
2947+
2948+
# Locate manifest. Either at the root, or one-level-down
2949+
# (common when tarballs include a top-level dir).
2950+
pack_root = tmp_root
2951+
if not (pack_root / "manifest.json").is_file():
2952+
children = [p for p in tmp_root.iterdir() if p.is_dir()]
2953+
if len(children) == 1 and (children[0] / "manifest.json").is_file():
2954+
pack_root = children[0]
2955+
else:
2956+
raise ConfigError(
2957+
"decisions install: tarball does not contain manifest.json at root "
2958+
"(or single top-level dir)"
2959+
)
2960+
2961+
data = json.loads((pack_root / "manifest.json").read_text(encoding="utf-8"))
2962+
name = str(data.get("name", "")) or pack_root.name
2963+
self.src_dir.mkdir(parents=True, exist_ok=True)
2964+
pack = self._build_pack(data, pack_root)
2965+
2966+
# Pre-flight: refuse if any dest matches a secret pattern.
2967+
for f in pack.files:
2968+
if self._matches_secret(Path(f.dest).name):
2969+
raise ConfigError(
2970+
f"pack '{name}': dest '{f.dest}' matches a secret pattern; "
2971+
"refusing to apply"
2972+
)
2973+
2974+
written: list[str] = []
2975+
skipped: list[str] = []
2976+
overwritten: list[str] = []
2977+
for f in pack.files:
2978+
src = pack_root / f.src
2979+
if not src.is_file():
2980+
raise ConfigError(f"pack '{name}': missing source file {f.src}")
2981+
target = self.src_dir / f.dest
2982+
exists = target.exists()
2983+
if exists and not force:
2984+
skipped.append(f.dest)
2985+
continue
2986+
target.parent.mkdir(parents=True, exist_ok=True)
2987+
target.write_bytes(src.read_bytes())
2988+
if f.mode is not None:
2989+
try:
2990+
mode_bits = int(f.mode, 8) & 0o7777
2991+
except ValueError as e:
2992+
raise ConfigError(
2993+
f"pack '{name}': invalid mode '{f.mode}' for {f.dest}"
2994+
) from e
2995+
with contextlib.suppress(OSError):
2996+
target.chmod(mode_bits)
2997+
if exists:
2998+
overwritten.append(f.dest)
2999+
else:
3000+
written.append(f.dest)
3001+
3002+
self._audit(
3003+
"decisions_install",
3004+
pack=name,
3005+
url=url,
3006+
sha256=sha256 or "",
3007+
force=force,
3008+
written=len(written),
3009+
overwritten=len(overwritten),
3010+
skipped=len(skipped),
3011+
)
3012+
return DecisionsApplyReport(
3013+
pack=name,
3014+
written=written,
3015+
skipped=skipped,
3016+
overwritten=overwritten,
3017+
dry_run=False,
3018+
)
3019+
28673020
def decisions_show(self, name: str) -> DecisionPack:
28683021
"""Return the manifest + file list for a single pack."""
28693022
root = self._decisions_root()

tests/test_manager.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,156 @@ def test_decisions_diff_unknown_pack_raises(cfg: ClaudeConfig) -> None:
934934
cfg.decisions_diff("does-not-exist")
935935

936936

937+
# --- remote packs (Phase B) ----------------------------------------------
938+
939+
940+
def _make_tarball(tmp_path: Path, manifest: dict, files: dict[str, str]) -> bytes:
941+
"""Build a gzip tarball with the given manifest + content files. Bytes only."""
942+
import io
943+
import json as _json
944+
import tarfile
945+
946+
buf = io.BytesIO()
947+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
948+
manifest_bytes = _json.dumps(manifest).encode("utf-8")
949+
m = tarfile.TarInfo(name="manifest.json")
950+
m.size = len(manifest_bytes)
951+
tar.addfile(m, io.BytesIO(manifest_bytes))
952+
for src, content in files.items():
953+
data = content.encode("utf-8")
954+
info = tarfile.TarInfo(name=src)
955+
info.size = len(data)
956+
tar.addfile(info, io.BytesIO(data))
957+
return buf.getvalue()
958+
959+
960+
def test_decisions_install_rejects_non_https(cfg: ClaudeConfig) -> None:
961+
with pytest.raises(ConfigError, match="must be https"):
962+
cfg.decisions_install("http://example.com/pack.tar.gz")
963+
964+
965+
def test_decisions_install_dry_run(cfg: ClaudeConfig) -> None:
966+
r = cfg.decisions_install("https://example.com/pack.tar.gz", dry_run=True)
967+
assert r.dry_run
968+
assert "pack.tar.gz" in r.pack
969+
970+
971+
def test_decisions_install_e2e_via_local_url(
972+
cfg: ClaudeConfig, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
973+
) -> None:
974+
"""End-to-end: mock urlopen to return a local tarball; verify files land."""
975+
tarball = _make_tarball(
976+
tmp_path,
977+
manifest={
978+
"name": "remote-test-pack",
979+
"description": "test",
980+
"version": "0.1.0",
981+
"files": [{"src": "files/hello.md", "dest": "commands/hello.md"}],
982+
},
983+
files={"files/hello.md": "# hello\n"},
984+
)
985+
986+
class _FakeResponse:
987+
def __init__(self, payload: bytes) -> None:
988+
self._payload = payload
989+
990+
def read(self, n: int) -> bytes:
991+
return self._payload[:n]
992+
993+
def __enter__(self) -> _FakeResponse:
994+
return self
995+
996+
def __exit__(self, *_a: object) -> None:
997+
return None
998+
999+
def fake_urlopen(req, timeout=30.0): # type: ignore[no-untyped-def]
1000+
return _FakeResponse(tarball)
1001+
1002+
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
1003+
1004+
r = cfg.decisions_install("https://example.com/pack.tar.gz", force=False)
1005+
assert r.written == ["commands/hello.md"]
1006+
target = cfg.src_dir / "commands" / "hello.md"
1007+
assert target.read_text(encoding="utf-8") == "# hello\n"
1008+
1009+
1010+
def test_decisions_install_sha256_mismatch(
1011+
cfg: ClaudeConfig, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1012+
) -> None:
1013+
"""Wrong sha256 -> ConfigError before extraction."""
1014+
tarball = _make_tarball(
1015+
tmp_path,
1016+
manifest={
1017+
"name": "x",
1018+
"description": "x",
1019+
"version": "0.0.1",
1020+
"files": [],
1021+
},
1022+
files={},
1023+
)
1024+
1025+
class _Resp:
1026+
def __init__(self, payload: bytes) -> None:
1027+
self._p = payload
1028+
1029+
def read(self, n: int) -> bytes:
1030+
return self._p[:n]
1031+
1032+
def __enter__(self) -> _Resp:
1033+
return self
1034+
1035+
def __exit__(self, *_a: object) -> None:
1036+
return None
1037+
1038+
monkeypatch.setattr(
1039+
"urllib.request.urlopen",
1040+
lambda req, timeout=30.0: _Resp(tarball),
1041+
)
1042+
with pytest.raises(ConfigError, match="sha256 mismatch"):
1043+
cfg.decisions_install(
1044+
"https://example.com/pack.tar.gz", sha256="00" * 32
1045+
)
1046+
1047+
1048+
def test_decisions_install_rejects_path_traversal(
1049+
cfg: ClaudeConfig, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1050+
) -> None:
1051+
"""Tarball containing `../escape` entry is refused."""
1052+
import io
1053+
import json as _json
1054+
import tarfile
1055+
1056+
buf = io.BytesIO()
1057+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
1058+
m_bytes = _json.dumps({"name": "bad", "files": []}).encode("utf-8")
1059+
m = tarfile.TarInfo(name="manifest.json")
1060+
m.size = len(m_bytes)
1061+
tar.addfile(m, io.BytesIO(m_bytes))
1062+
bad = tarfile.TarInfo(name="../escape.md")
1063+
bad.size = 1
1064+
tar.addfile(bad, io.BytesIO(b"x"))
1065+
1066+
class _R:
1067+
def __init__(self, p: bytes) -> None:
1068+
self._p = p
1069+
1070+
def read(self, n: int) -> bytes:
1071+
return self._p[:n]
1072+
1073+
def __enter__(self) -> _R:
1074+
return self
1075+
1076+
def __exit__(self, *_a: object) -> None:
1077+
return None
1078+
1079+
monkeypatch.setattr(
1080+
"urllib.request.urlopen",
1081+
lambda req, timeout=30.0: _R(buf.getvalue()),
1082+
)
1083+
with pytest.raises(ConfigError, match="escapes root"):
1084+
cfg.decisions_install("https://example.com/bad.tar.gz")
1085+
1086+
9371087
# --- memory hygiene (Phase C) --------------------------------------------
9381088

9391089

0 commit comments

Comments
 (0)