diff --git a/agents/scripts/install_extras.py b/agents/scripts/install_extras.py index fc02174..c1e2c91 100755 --- a/agents/scripts/install_extras.py +++ b/agents/scripts/install_extras.py @@ -371,9 +371,25 @@ def _install_one( # sha-verify source against MANIFEST (supply-chain guard). rel = str(src.resolve().relative_to(pack_root.resolve())).replace("\\", "/") + if not manifest: + report.operations.append(InstallOp( + slug=slug, source=src, target=target, + action="refused", + reason=("MANIFEST.sha256 absent -- refusing to install unverified " + "pack source. Regenerate with build_manifest.py."), + )) + return expected = manifest.get(rel) actual = _sha256_of(src) - if expected is not None and expected != actual: + if expected is None: + report.operations.append(InstallOp( + slug=slug, source=src, target=target, + action="refused", + reason=(f"{rel}: not in MANIFEST.sha256 -- refusing to install " + f"unverified pack source. Regenerate with build_manifest.py."), + )) + return + if expected != actual: report.operations.append(InstallOp( slug=slug, source=src, target=target, action="refused", @@ -381,11 +397,6 @@ def _install_one( f"-- possible supply-chain tampering"), )) return - if expected is None: - report.warnings.append( - f"{slug}: source not in MANIFEST.sha256 " - f"(regenerate with build_manifest.py)" - ) if dry_run: report.operations.append(InstallOp( diff --git a/agents/scripts/scan_agent_safety.py b/agents/scripts/scan_agent_safety.py index b931370..dfc8eee 100755 --- a/agents/scripts/scan_agent_safety.py +++ b/agents/scripts/scan_agent_safety.py @@ -230,17 +230,57 @@ def _scan_text(text: str, rel: str) -> list[Finding]: } +# Pack-owned files that ship inside an otherwise-excluded directory and +# DO end up loaded into the IDE / LLM context after `upgrade.py --apply`. +# These must be scanned even when their parent directory is in +# `_DEFAULT_EXCLUDE_DIR_NAMES`. Each entry is a relative path from a +# scan target's root. +_FORCE_INCLUDE_RELS: set[str] = { + "templates/rules/protocol-enforcement.mdc", + # `project-domain-rules.mdc.template` is a starter, not a hard-applied + # rule, but ships pack-owned and is reachable via integration paths. + "templates/rules/project-domain-rules.mdc.template", +} + +# Suffixes considered "agent text" and therefore in scope for the scanner. +# `.mdc` is the Cursor rules format -- a file that loads into every IDE +# session via `alwaysApply: true`. Skipping it leaves a structural blind +# spot for the only mandatory rule the pack ships. +_AGENT_SUFFIXES: tuple[str, ...] = (".md", ".mdc", ".mdc.template") + + +def _has_agent_suffix(p: Path) -> bool: + name = p.name + return any(name.endswith(s) for s in _AGENT_SUFFIXES) + + def _iter_agent_files(targets: list[Path], extra_exclude: set[str] | None = None) -> list[Path]: files: list[Path] = [] + seen: set[Path] = set() excl = set(_DEFAULT_EXCLUDE_DIR_NAMES) | (extra_exclude or set()) for t in targets: - if t.is_file() and t.suffix == ".md": - files.append(t) - elif t.is_dir(): - for p in sorted(t.rglob("*.md")): - if any(seg in excl for seg in p.relative_to(t).parts): + if t.is_file() and _has_agent_suffix(t): + if t not in seen: + seen.add(t) + files.append(t) + continue + if not t.is_dir(): + continue + for pattern in ("*.md", "*.mdc", "*.mdc.template"): + for p in t.rglob(pattern): + if p in seen: + continue + rel = p.relative_to(t) + rel_posix = rel.as_posix() + if rel_posix in _FORCE_INCLUDE_RELS: + seen.add(p) + files.append(p) + continue + if any(seg in excl for seg in rel.parts): continue + seen.add(p) files.append(p) + files.sort() return files diff --git a/agents/scripts/upgrade.py b/agents/scripts/upgrade.py index 5f1e225..67e6294 100755 --- a/agents/scripts/upgrade.py +++ b/agents/scripts/upgrade.py @@ -326,14 +326,25 @@ def _verify_source_integrity(op: "UpgradeOp", pack_root: Path, manifest: dict[str, str]) -> str: """Return empty string if OK, a non-empty reason otherwise.""" if not manifest: - return "" # no manifest -> best-effort; caller logs a warning once + # Caller is responsible for refusing to apply when the manifest is + # missing entirely. We flag every op so apply_plan never silently + # copies unverified files. + try: + rel = op.source.resolve().relative_to(pack_root.resolve()).as_posix() + except ValueError: + rel = str(op.source) + return (f"{rel}: MANIFEST.sha256 absent -- refusing to install " + f"unverified pack-owned file") try: rel = op.source.resolve().relative_to(pack_root.resolve()).as_posix() except ValueError: - return "" # source outside pack root (shouldn't happen) + return (f"{op.source}: pack-owned source resolved outside pack root -- " + f"refusing to install") expected = manifest.get(rel) if expected is None: - return "" # source not in manifest -> not a tampering signal + return (f"{rel}: not in MANIFEST.sha256 -- refusing to install " + f"unverified pack-owned file. Regenerate the manifest with " + f"agents/scripts/build_manifest.py.") actual = _sha256_of(op.source) if actual != expected: return (f"{rel}: manifest expected {expected[:12]}..., " @@ -442,22 +453,26 @@ def apply_plan(report: UpgradeReport, dry_run: bool, pack_root: Path | None = None, project_root: Path | None = None, snapshot: bool = True) -> None: - """Copy files, guarded by MANIFEST.sha256. Any source whose sha - doesn't match the manifest is refused (appended to report.errors) - and NOT copied. If MANIFEST is missing entirely we fall back to - best-effort copy and add a single warning.""" + """Copy files, guarded by MANIFEST.sha256. Fail-closed: every + pack-owned source must match a sha entry in MANIFEST.sha256, or it + is refused and not copied. If MANIFEST.sha256 is missing entirely, + nothing is copied -- the operator must regenerate it via + build_manifest.py before retrying.""" manifest: dict[str, str] = {} + manifest_missing = False if pack_root is not None: manifest = _load_manifest(pack_root) if not manifest: + manifest_missing = True report.errors.append( - "MANIFEST.sha256 not found in pack -- " - "cannot verify source integrity; proceeding without check. " + "MANIFEST.sha256 not found in pack -- REFUSING to apply. " "Regenerate with: python3 agents/scripts/build_manifest.py", ) # Pre-apply snapshot so --rollback can undo this run. - if not dry_run and snapshot and project_root is not None: + # Skip when the manifest is missing -- we are not going to apply + # anything in that case, so a snapshot would be pure noise. + if not dry_run and snapshot and project_root is not None and not manifest_missing: try: tarball = _take_snapshot(report, project_root) if tarball is not None: @@ -471,7 +486,7 @@ def apply_plan(report: UpgradeReport, dry_run: bool, continue if dry_run: continue - if pack_root is not None and manifest: + if pack_root is not None: problem = _verify_source_integrity(op, pack_root, manifest) if problem: report.errors.append(f"REFUSED: {problem}") @@ -540,12 +555,41 @@ def rollback(project_root: Path, which: str | None = None) -> "UpgradeReport": except Exception: creations = [] + proj_resolved = project_root.resolve() + + def _safe_join(base: Path, name: str) -> Path | None: + # Refuse absolute paths and any name that escapes `base` after + # resolution. Also refuse symlinks at the destination so a + # poisoned snapshot can't redirect a write through `~/foo`. + if not name or name.startswith("/") or "\x00" in name: + return None + candidate = (base / name) + try: + resolved = candidate.resolve() + except Exception: + return None + try: + resolved.relative_to(base) + except ValueError: + return None + if candidate.is_symlink(): + return None + return resolved + restored: list[str] = [] with tarfile.open(target_tar, "r:gz") as tar: for member in tar.getmembers(): if not member.isfile(): continue - out = project_root / member.name + if member.issym() or member.islnk(): + report.errors.append( + f"refused symlink/hardlink in snapshot: {member.name}") + continue + out = _safe_join(proj_resolved, member.name) + if out is None: + report.errors.append( + f"refused unsafe path in snapshot: {member.name}") + continue out.parent.mkdir(parents=True, exist_ok=True) try: extracted = tar.extractfile(member) @@ -559,8 +603,12 @@ def rollback(project_root: Path, which: str | None = None) -> "UpgradeReport": # Delete files that the apply CREATED (recorded at snapshot time). removed: list[str] = [] for rel in creations: - p = project_root / rel - if p.exists() and p.is_file(): + p = _safe_join(proj_resolved, rel) + if p is None: + report.errors.append( + f"refused unsafe creation entry: {rel}") + continue + if p.exists() and p.is_file() and not p.is_symlink(): try: p.unlink() removed.append(rel)