Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions agents/scripts/install_extras.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,21 +371,32 @@ 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",
reason=(f"MANIFEST expected sha {expected[:12]}…, actual {actual[:12]}… "
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(
Expand Down
50 changes: 45 additions & 5 deletions agents/scripts/scan_agent_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
76 changes: 62 additions & 14 deletions agents/scripts/upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}..., "
Expand Down Expand Up @@ -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:
Expand All @@ -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}")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down