agent-exec transport b11015860dac4bb8a1943889211374b7/0dd2eb729ad2
This commit is contained in:
+196
-230
@@ -1,4 +1,10 @@
|
||||
"""Recoverable agent-exec controller: validate, run, apply transports, admit provenance."""
|
||||
"""Recoverable agent-exec controller: validate, run, apply transports, admit staged content.
|
||||
|
||||
Local history MACs and the pre-commit ``admit`` check are local integrity and
|
||||
tamper evidence only (SELF). They are not GATE, not a trusted identity, and do
|
||||
not protect against the same OS user. Delivery trust comes from exact-HEAD CI
|
||||
or an independent REPLAY.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
@@ -29,7 +35,11 @@ _REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from scripts.agent_exec_adapters import build_invocation, probe_adapter
|
||||
from scripts.agent_exec_adapters import (
|
||||
build_invocation,
|
||||
decode_terminal_record,
|
||||
probe_adapter,
|
||||
)
|
||||
from scripts.agent_exec_config import (
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS,
|
||||
SECRET_VALUE_RE,
|
||||
@@ -183,7 +193,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
p_admit = sub.add_parser(
|
||||
"admit",
|
||||
help="Admit staged paths backed by a success transport or host exemption",
|
||||
help=(
|
||||
"Locally check staged paths against a success transport or host "
|
||||
"exemption (local integrity evidence, not GATE)"
|
||||
),
|
||||
)
|
||||
p_admit.add_argument("--repo", required=True)
|
||||
|
||||
@@ -791,7 +804,7 @@ def _run_attempt(
|
||||
pointer_delta = _snapshot_delta(pointer_before, pointer_after)
|
||||
canonical_delta = _snapshot_delta(canonical_before, canonical_after)
|
||||
|
||||
worker_result = _load_worker_result(result_path, proc_meta["stdout_tail"])
|
||||
worker_result = _load_worker_result(profile.adapter, result_path, proc_meta)
|
||||
summary = _bounded_summary(
|
||||
proc_meta.get("summary_source") or (worker_result or {}).get("summary") or "",
|
||||
cfg.execution.summary_bytes,
|
||||
@@ -922,7 +935,17 @@ def _run_attempt(
|
||||
cleanup_extra={"reason": "worker fail-stop terminal"},
|
||||
)
|
||||
|
||||
# Never report success from worker prose alone — require exit + admin + in-scope diff + transport.
|
||||
completed = bool(
|
||||
worker_result and worker_result.get("terminal_status") == "completed"
|
||||
)
|
||||
if clean_exit and changed and not completed:
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"worker exited 0 with changes but no valid completed terminal record",
|
||||
cleanup_extra={"reason": "missing or invalid terminal record"},
|
||||
)
|
||||
|
||||
# Success requires exit 0 + completed terminal record + clean admin + in-scope diff + transport.
|
||||
if clean_exit and changed and not out_of_scope:
|
||||
# Recheck path bounds immediately before host-owned transport.
|
||||
try:
|
||||
@@ -975,121 +998,23 @@ def _run_attempt(
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
|
||||
if clean_exit and not changed:
|
||||
# No diff does not prove an authorized external effect was reconciled.
|
||||
if _external_side_effects_unproven(unit, worker_result):
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
(
|
||||
"worker exited 0 without in-scope changes; "
|
||||
"external side effects cannot be reconciled"
|
||||
),
|
||||
cleanup_extra={
|
||||
"reason": "network or external_cost authorized without trustworthy receipt",
|
||||
"network": unit.network,
|
||||
"external_cost": unit.external_cost,
|
||||
},
|
||||
)
|
||||
restored = _restore_worktree(repo, worktree, base["head"])
|
||||
if not restored:
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"exact restore failed",
|
||||
cleanup_extra={"reason": "exact restore failed"},
|
||||
)
|
||||
return AttemptRecord(
|
||||
attempt_id=attempt_id,
|
||||
profile=profile.name,
|
||||
adapter=profile.adapter,
|
||||
terminal_status="failed-clean",
|
||||
reason="worker exited 0 without in-scope changes",
|
||||
worktree=str(worktree),
|
||||
pid=proc_meta["pid"],
|
||||
exit_code=proc_meta["exit_code"],
|
||||
changed_paths=[],
|
||||
inventory_paths=[],
|
||||
raw_log=str(raw_log),
|
||||
summary=summary,
|
||||
cleanup={"exact_restore": True},
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
|
||||
# Nonzero / aborted without trustworthy external reconciliation.
|
||||
if _external_side_effects_unproven(unit, worker_result):
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
(
|
||||
f"worker failed with exit {proc_meta['exit_code']}; "
|
||||
"external side effects cannot be reconciled"
|
||||
),
|
||||
cleanup_extra={
|
||||
"reason": "network or external_cost authorized without trustworthy receipt",
|
||||
"network": unit.network,
|
||||
"external_cost": unit.external_cost,
|
||||
},
|
||||
)
|
||||
|
||||
# Controlled case: network=false and external_cost=false may become failed-clean
|
||||
# only after exact worktree restoration proves no unknown authorized external object.
|
||||
if changed:
|
||||
restored = _restore_worktree(repo, worktree, base["head"])
|
||||
if restored:
|
||||
return AttemptRecord(
|
||||
attempt_id=attempt_id,
|
||||
profile=profile.name,
|
||||
adapter=profile.adapter,
|
||||
terminal_status="failed-clean",
|
||||
reason=f"worker failed with exit {proc_meta['exit_code']}; restored",
|
||||
worktree=str(worktree),
|
||||
pid=proc_meta["pid"],
|
||||
exit_code=proc_meta["exit_code"],
|
||||
changed_paths=changed,
|
||||
inventory_paths=list(changed),
|
||||
raw_log=str(raw_log),
|
||||
summary=summary,
|
||||
cleanup={
|
||||
"exact_restore": True,
|
||||
"external_objects_reconciled": True,
|
||||
"network": False,
|
||||
"external_cost": False,
|
||||
},
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"worker failed; exact restore not proven",
|
||||
)
|
||||
|
||||
restored = _restore_worktree(repo, worktree, base["head"])
|
||||
if not restored:
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
"exact restore failed",
|
||||
cleanup_extra={"reason": "exact restore failed"},
|
||||
)
|
||||
return AttemptRecord(
|
||||
attempt_id=attempt_id,
|
||||
profile=profile.name,
|
||||
adapter=profile.adapter,
|
||||
terminal_status="failed-clean",
|
||||
reason=f"worker failed with exit {proc_meta['exit_code']}",
|
||||
worktree=str(worktree),
|
||||
pid=proc_meta["pid"],
|
||||
exit_code=proc_meta["exit_code"],
|
||||
changed_paths=[],
|
||||
inventory_paths=[],
|
||||
raw_log=str(raw_log),
|
||||
summary=summary,
|
||||
cleanup={
|
||||
"exact_restore": True,
|
||||
"external_objects_reconciled": True,
|
||||
"network": False,
|
||||
"external_cost": False,
|
||||
# Launched attempts have no enforced network or external-cost isolation, and
|
||||
# the unit's declared network/external_cost booleans are not proof. A
|
||||
# restored private clone therefore cannot make a launched failure clean.
|
||||
if clean_exit:
|
||||
reason = "worker exited 0 without in-scope changes"
|
||||
else:
|
||||
reason = f"worker failed with exit {proc_meta['exit_code']}"
|
||||
return _preserved(
|
||||
"failed-unknown",
|
||||
f"{reason}; external side effects are not mechanically reconciled",
|
||||
cleanup_extra={
|
||||
"reason": "external side effects not mechanically reconciled",
|
||||
"declared_network": unit.network,
|
||||
"declared_external_cost": unit.external_cost,
|
||||
},
|
||||
observed_version=probe.observed_version,
|
||||
)
|
||||
|
||||
|
||||
def _supervise(
|
||||
argv: tuple[str, ...],
|
||||
*,
|
||||
@@ -1113,7 +1038,7 @@ def _supervise(
|
||||
exit_code: int | None = None
|
||||
sig: int | None = None
|
||||
pid: int | None = None
|
||||
stdout_tail = _ByteTail(OUTPUT_TAIL_BYTES)
|
||||
stdout_tail = _StdoutTail(OUTPUT_TAIL_BYTES)
|
||||
stderr_tail = _ByteTail(OUTPUT_TAIL_BYTES)
|
||||
# Bounded prefix: raw_cap + exact-secret lookahead. Redact the complete
|
||||
# prefix once, write at most raw_cap, then discard further drained bytes.
|
||||
@@ -1386,6 +1311,7 @@ def _supervise(
|
||||
"signal": sig,
|
||||
"stdout_tail": stdout_text,
|
||||
"stderr_tail": stderr_text,
|
||||
"terminal_lines": stdout_tail.terminal_lines(),
|
||||
"summary_source": summary_source,
|
||||
}
|
||||
|
||||
@@ -1408,6 +1334,74 @@ class _ByteTail:
|
||||
return bytes(self._data).decode("utf-8", "replace")
|
||||
|
||||
|
||||
TERMINAL_LINE_MAX_BYTES = 1 << 20
|
||||
TERMINAL_LINE_KEEP = 32
|
||||
_TERMINAL_ENVELOPE_RE = re.compile(
|
||||
rb'"(?:type|event)"\s*:\s*"(?:result|message_end)"'
|
||||
)
|
||||
_TERMINAL_HINT_RE = re.compile(rb'terminal_status|"is_error"')
|
||||
|
||||
|
||||
class _StdoutTail(_ByteTail):
|
||||
"""Byte tail that also keeps recent complete lines that may be terminal envelopes.
|
||||
|
||||
A final envelope can sit behind a later event larger than the byte tail
|
||||
(for example PI ``agent_end``), so candidate lines are kept separately.
|
||||
An oversized envelope line is recorded as ``None`` so decoding fails closed.
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int) -> None:
|
||||
super().__init__(limit)
|
||||
self._line = bytearray()
|
||||
self._overflow_head: bytes | None = None
|
||||
self._lines: list[bytes | None] = []
|
||||
|
||||
def extend(self, chunk: bytes) -> None:
|
||||
super().extend(chunk)
|
||||
start = 0
|
||||
while start <= len(chunk):
|
||||
nl = chunk.find(b"\n", start)
|
||||
piece = chunk[start:] if nl == -1 else chunk[start:nl]
|
||||
self._append_piece(piece)
|
||||
if nl == -1:
|
||||
return
|
||||
self._end_line()
|
||||
start = nl + 1
|
||||
|
||||
def _append_piece(self, piece: bytes) -> None:
|
||||
if self._overflow_head is not None:
|
||||
return
|
||||
if len(self._line) + len(piece) > TERMINAL_LINE_MAX_BYTES:
|
||||
self._overflow_head = bytes(self._line[:256] + piece[:256])[:256]
|
||||
self._line.clear()
|
||||
return
|
||||
self._line.extend(piece)
|
||||
|
||||
def _end_line(self) -> None:
|
||||
if self._overflow_head is not None:
|
||||
if _TERMINAL_ENVELOPE_RE.search(self._overflow_head):
|
||||
self._keep(None)
|
||||
self._overflow_head = None
|
||||
return
|
||||
line = bytes(self._line)
|
||||
self._line.clear()
|
||||
if _TERMINAL_ENVELOPE_RE.search(line) or _TERMINAL_HINT_RE.search(line):
|
||||
self._keep(line)
|
||||
|
||||
def _keep(self, line: bytes | None) -> None:
|
||||
self._lines.append(line)
|
||||
if len(self._lines) > TERMINAL_LINE_KEEP:
|
||||
del self._lines[0]
|
||||
|
||||
def terminal_lines(self) -> list[str | None]:
|
||||
if self._line or self._overflow_head is not None:
|
||||
self._end_line()
|
||||
return [
|
||||
None if line is None else line.decode("utf-8", "replace")
|
||||
for line in self._lines
|
||||
]
|
||||
|
||||
|
||||
def _kill_process_group(pid: int) -> None:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
@@ -1695,20 +1689,6 @@ def _worktree_pointer_snapshot(
|
||||
return snap
|
||||
|
||||
|
||||
def _external_side_effects_unproven(
|
||||
unit: UnitPacket, worker_result: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""Network or external_cost units cannot be failed-clean without reconciliation.
|
||||
|
||||
A clean exit with no file diff is included. failed-clean still requires exact
|
||||
restore plus affirmative evidence that no authorized external object remains
|
||||
unknown. Worker prose is not that evidence; only an explicit trustworthy
|
||||
reconciliation record would clear the unit, and none is accepted here.
|
||||
"""
|
||||
del worker_result # Worker prose alone never proves external reconciliation.
|
||||
return bool(unit.network or unit.external_cost)
|
||||
|
||||
|
||||
def _is_within_root(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
@@ -1818,20 +1798,6 @@ def _create_transport_commit(
|
||||
return ref
|
||||
|
||||
|
||||
def _restore_worktree(repo: Path, worktree: Path, base_head: str) -> bool:
|
||||
"""Exact-restore the private clone then remove it after verification."""
|
||||
try:
|
||||
_git(worktree, "reset", "--hard", base_head)
|
||||
_git(worktree, "clean", "-fdx")
|
||||
head = _git(worktree, "rev-parse", "HEAD").stdout.strip()
|
||||
dirty = _git(worktree, "status", "--porcelain").stdout.strip()
|
||||
ok = head == base_head and not dirty
|
||||
_safe_remove_worktree(repo, worktree)
|
||||
return ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _safe_remove_worktree(repo: Path, worktree: Path) -> None:
|
||||
"""Remove a private attempt clone directory (not a linked worktree)."""
|
||||
del repo # Private clones are not registered linked worktrees.
|
||||
@@ -1840,29 +1806,21 @@ def _safe_remove_worktree(repo: Path, worktree: Path) -> None:
|
||||
|
||||
|
||||
def _load_worker_result(
|
||||
result_path: Path, stdout_tail: str
|
||||
adapter: str, result_path: Path, proc_meta: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the worker's valid terminal record, or None when absent/invalid."""
|
||||
result_text: str | None = None
|
||||
if result_path.is_file():
|
||||
try:
|
||||
data = json.loads(result_path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
# Try last JSON object in stdout.
|
||||
text = stdout_tail.strip()
|
||||
if not text:
|
||||
return None
|
||||
for candidate in (text, text[text.rfind("{") :] if "{" in text else ""):
|
||||
if not candidate.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(candidate)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
result_text = result_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
result_text = None
|
||||
return decode_terminal_record(
|
||||
adapter,
|
||||
event_lines=proc_meta.get("terminal_lines") or [],
|
||||
stdout_tail=proc_meta.get("stdout_tail") or "",
|
||||
result_text=result_text,
|
||||
)
|
||||
|
||||
|
||||
def _base_receipt(
|
||||
@@ -1958,7 +1916,7 @@ def _finish_run(
|
||||
)
|
||||
history_path: Path | None = None
|
||||
try:
|
||||
history_path, attestation = _append_execution_history(
|
||||
history_path, integrity_mac = _append_execution_history(
|
||||
repo, receipt, secret_values
|
||||
)
|
||||
recorded: dict[str, Any] = {
|
||||
@@ -1967,8 +1925,8 @@ def _finish_run(
|
||||
"record_id": receipt.get("run_id"),
|
||||
"run_id": receipt.get("run_id"),
|
||||
}
|
||||
if attestation:
|
||||
recorded["attestation"] = attestation
|
||||
if integrity_mac:
|
||||
recorded["local_integrity_mac"] = integrity_mac
|
||||
receipt["history"] = recorded
|
||||
except Exception as exc:
|
||||
receipt["history"] = {
|
||||
@@ -2001,12 +1959,12 @@ def _append_execution_history(
|
||||
history_path = history_dir / "execution-history.jsonl"
|
||||
if history_path.is_symlink():
|
||||
raise RuntimeError("agent-exec history file must not be a symlink")
|
||||
attestation: str | None = None
|
||||
integrity_mac: str | None = None
|
||||
if receipt.get("terminal_status") == "success":
|
||||
_bind_success_transport_commit(repo, receipt)
|
||||
key = _ensure_attestation_key(repo)
|
||||
key = _ensure_local_integrity_key(repo)
|
||||
record = _execution_history_record(repo, receipt)
|
||||
line, attestation = _signed_history_line(record, key, secret_values)
|
||||
line, integrity_mac = _integrity_history_line(record, key, secret_values)
|
||||
else:
|
||||
record = _execution_history_record(repo, receipt)
|
||||
line = _redact_text(_canonical_json(record), secret_values) + "\n"
|
||||
@@ -2027,7 +1985,7 @@ def _append_execution_history(
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
finally:
|
||||
os.close(fd)
|
||||
return history_path, attestation
|
||||
return history_path, integrity_mac
|
||||
|
||||
|
||||
def _execution_history_record(repo: Path, receipt: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -2335,8 +2293,12 @@ def _valid_transport_ref(ref: object) -> str | None:
|
||||
return ref
|
||||
|
||||
|
||||
_ATTESTATION_KEY_NAME = "attestation.key"
|
||||
_ATTESTATION_KEY_BYTES = 32
|
||||
# Legacy on-disk names keep existing local history readable. The MAC is local
|
||||
# integrity/tamper evidence only: any process running as the same OS user can
|
||||
# read the key and forge records, so it is never GATE or a trusted identity.
|
||||
_LOCAL_INTEGRITY_KEY_NAME = "attestation.key"
|
||||
_LOCAL_INTEGRITY_FIELD = "attestation"
|
||||
_LOCAL_INTEGRITY_KEY_BYTES = 32
|
||||
|
||||
|
||||
def _git_common_dir(repo: Path) -> Path:
|
||||
@@ -2363,27 +2325,27 @@ def _canonical_json(data: dict[str, Any]) -> str:
|
||||
return json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _history_attestation(key: bytes, record: dict[str, Any]) -> str:
|
||||
body = {field: value for field, value in record.items() if field != "attestation"}
|
||||
def _local_integrity_mac(key: bytes, record: dict[str, Any]) -> str:
|
||||
body = {field: value for field, value in record.items() if field != _LOCAL_INTEGRITY_FIELD}
|
||||
return hmac.new(key, _canonical_json(body).encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _history_attestation_ok(key: bytes, record: dict[str, Any]) -> bool:
|
||||
presented = record.get("attestation")
|
||||
def _local_integrity_ok(key: bytes, record: dict[str, Any]) -> bool:
|
||||
presented = record.get(_LOCAL_INTEGRITY_FIELD)
|
||||
if not isinstance(presented, str) or len(presented) != 64:
|
||||
return False
|
||||
return hmac.compare_digest(_history_attestation(key, record), presented)
|
||||
return hmac.compare_digest(_local_integrity_mac(key, record), presented)
|
||||
|
||||
|
||||
def _signed_history_line(
|
||||
def _integrity_history_line(
|
||||
record: dict[str, Any], key: bytes, secret_values: set[str]
|
||||
) -> tuple[str, str]:
|
||||
text = _redact_text(_canonical_json(record), secret_values)
|
||||
body = json.loads(text)
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError("history record redaction produced non-object JSON")
|
||||
mac = _history_attestation(key, body)
|
||||
body["attestation"] = mac
|
||||
mac = _local_integrity_mac(key, body)
|
||||
body[_LOCAL_INTEGRITY_FIELD] = mac
|
||||
return _canonical_json(body) + "\n", mac
|
||||
|
||||
|
||||
@@ -2456,22 +2418,22 @@ def _locked_agent_exec_dir(repo: Path, *, create: bool) -> Iterator[int | None]:
|
||||
|
||||
def _read_key_at(dir_fd: int) -> bytes:
|
||||
fd = os.open(
|
||||
_ATTESTATION_KEY_NAME,
|
||||
_LOCAL_INTEGRITY_KEY_NAME,
|
||||
os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC,
|
||||
dir_fd=dir_fd,
|
||||
)
|
||||
try:
|
||||
current = os.fstat(fd)
|
||||
if not stat.S_ISREG(current.st_mode):
|
||||
raise RuntimeError("attestation key must be a regular file")
|
||||
raise RuntimeError("local integrity key must be a regular file")
|
||||
if stat.S_IMODE(current.st_mode) != 0o600:
|
||||
raise RuntimeError("attestation key mode must be 0600")
|
||||
if current.st_size != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
raise RuntimeError("local integrity key mode must be 0600")
|
||||
if current.st_size != _LOCAL_INTEGRITY_KEY_BYTES:
|
||||
raise RuntimeError("local integrity key must be 32 bytes")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
key = os.read(fd, _ATTESTATION_KEY_BYTES)
|
||||
if len(key) != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
key = os.read(fd, _LOCAL_INTEGRITY_KEY_BYTES)
|
||||
if len(key) != _LOCAL_INTEGRITY_KEY_BYTES:
|
||||
raise RuntimeError("local integrity key must be 32 bytes")
|
||||
return key
|
||||
finally:
|
||||
os.close(fd)
|
||||
@@ -2479,63 +2441,63 @@ def _read_key_at(dir_fd: int) -> bytes:
|
||||
|
||||
def _key_is_symlink(dir_fd: int) -> bool:
|
||||
try:
|
||||
info = os.stat(_ATTESTATION_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False)
|
||||
info = os.stat(_LOCAL_INTEGRITY_KEY_NAME, dir_fd=dir_fd, follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return stat.S_ISLNK(info.st_mode)
|
||||
|
||||
|
||||
def _read_attestation_key(repo: Path) -> bytes | None:
|
||||
"""Read the local attestation key. Never creates one and never follows links."""
|
||||
def _read_local_integrity_key(repo: Path) -> bytes | None:
|
||||
"""Read the local integrity key. Never creates one and never follows links."""
|
||||
with _locked_agent_exec_dir(repo, create=False) as dir_fd:
|
||||
if dir_fd is None:
|
||||
return None
|
||||
if _key_is_symlink(dir_fd):
|
||||
raise RuntimeError("attestation key must not be a symlink")
|
||||
raise RuntimeError("local integrity key must not be a symlink")
|
||||
try:
|
||||
return _read_key_at(dir_fd)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise _symlink_oserror(exc, "attestation key must not be a symlink") from exc
|
||||
raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc
|
||||
|
||||
|
||||
def _ensure_attestation_key(repo: Path) -> bytes:
|
||||
def _ensure_local_integrity_key(repo: Path) -> bytes:
|
||||
"""Create or read the 32-byte key under the Git common dir.
|
||||
|
||||
This binds controller history to later apply/admit checks. It does not
|
||||
protect against the same OS user deliberately reading the key file.
|
||||
The MAC lets later apply/admit checks detect accidental or casual edits of
|
||||
local history. It is not an identity: the same OS user can read the key.
|
||||
"""
|
||||
with _locked_agent_exec_dir(repo, create=True) as dir_fd:
|
||||
if dir_fd is None:
|
||||
raise RuntimeError("agent-exec history directory is unavailable")
|
||||
if _key_is_symlink(dir_fd):
|
||||
raise RuntimeError("attestation key must not be a symlink")
|
||||
raise RuntimeError("local integrity key must not be a symlink")
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||
try:
|
||||
fd = os.open(_ATTESTATION_KEY_NAME, flags, 0o600, dir_fd=dir_fd)
|
||||
fd = os.open(_LOCAL_INTEGRITY_KEY_NAME, flags, 0o600, dir_fd=dir_fd)
|
||||
except OSError as exc:
|
||||
raise _symlink_oserror(exc, "attestation key must not be a symlink") from exc
|
||||
raise _symlink_oserror(exc, "local integrity key must not be a symlink") from exc
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
current = os.fstat(fd)
|
||||
if not stat.S_ISREG(current.st_mode):
|
||||
raise RuntimeError("attestation key must be a regular file")
|
||||
raise RuntimeError("local integrity key must be a regular file")
|
||||
if current.st_size == 0:
|
||||
key = os.urandom(_ATTESTATION_KEY_BYTES)
|
||||
key = os.urandom(_LOCAL_INTEGRITY_KEY_BYTES)
|
||||
written = os.write(fd, key)
|
||||
if written != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key write was short")
|
||||
if written != _LOCAL_INTEGRITY_KEY_BYTES:
|
||||
raise RuntimeError("local integrity key write was short")
|
||||
os.fsync(fd)
|
||||
return key
|
||||
if current.st_size != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
if current.st_size != _LOCAL_INTEGRITY_KEY_BYTES:
|
||||
raise RuntimeError("local integrity key must be 32 bytes")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
key = os.read(fd, _ATTESTATION_KEY_BYTES)
|
||||
if len(key) != _ATTESTATION_KEY_BYTES:
|
||||
raise RuntimeError("attestation key must be 32 bytes")
|
||||
key = os.read(fd, _LOCAL_INTEGRITY_KEY_BYTES)
|
||||
if len(key) != _LOCAL_INTEGRITY_KEY_BYTES:
|
||||
raise RuntimeError("local integrity key must be 32 bytes")
|
||||
if stat.S_IMODE(os.fstat(fd).st_mode) != 0o600:
|
||||
raise RuntimeError("attestation key mode must be 0600")
|
||||
raise RuntimeError("local integrity key mode must be 0600")
|
||||
return key
|
||||
finally:
|
||||
os.close(fd)
|
||||
@@ -2574,7 +2536,7 @@ def _transport_covers(
|
||||
required: set[str],
|
||||
key: bytes | None,
|
||||
) -> bool:
|
||||
if key is None or not _history_attestation_ok(key, record):
|
||||
if key is None or not _local_integrity_ok(key, record):
|
||||
return False
|
||||
if record.get("schema") != HISTORY_SCHEMA:
|
||||
return False
|
||||
@@ -2587,32 +2549,32 @@ def _transport_covers(
|
||||
return False
|
||||
ref = _valid_transport_ref(transport.get("ref"))
|
||||
parent = transport.get("parent")
|
||||
signed_commit = transport.get("commit")
|
||||
if ref is None or not isinstance(parent, str) or not isinstance(signed_commit, str):
|
||||
recorded_commit = transport.get("commit")
|
||||
if ref is None or not isinstance(parent, str) or not isinstance(recorded_commit, str):
|
||||
return False
|
||||
if _resolve_commit(repo, ref) != signed_commit:
|
||||
if _resolve_commit(repo, ref) != recorded_commit:
|
||||
return False
|
||||
if _resolve_commit(repo, parent) != head:
|
||||
return False
|
||||
if _commit_parents(repo, signed_commit) != [head]:
|
||||
if _commit_parents(repo, recorded_commit) != [head]:
|
||||
return False
|
||||
changed = record.get("changed_paths")
|
||||
if not isinstance(changed, list) or any(not isinstance(path, str) for path in changed):
|
||||
return False
|
||||
diff_paths = _diff_paths(repo, head, signed_commit)
|
||||
diff_paths = _diff_paths(repo, head, recorded_commit)
|
||||
if _non_exempt(changed) != required or _non_exempt(diff_paths) != required:
|
||||
return False
|
||||
return _index_matches_commit(repo, signed_commit, required)
|
||||
return _index_matches_commit(repo, recorded_commit, required)
|
||||
|
||||
|
||||
def _apply_signed_history_ok(
|
||||
def _apply_local_integrity_ok(
|
||||
repo: Path,
|
||||
receipt: dict[str, Any],
|
||||
*,
|
||||
commit: str,
|
||||
base_head: str,
|
||||
) -> bool:
|
||||
key = _read_attestation_key(repo)
|
||||
key = _read_local_integrity_key(repo)
|
||||
if key is None:
|
||||
return False
|
||||
run_id = receipt.get("run_id")
|
||||
@@ -2648,7 +2610,7 @@ def _apply_signed_history_ok(
|
||||
continue
|
||||
if rec_transport.get("commit") != commit:
|
||||
continue
|
||||
if _history_attestation_ok(key, record):
|
||||
if _local_integrity_ok(key, record):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -2717,13 +2679,13 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int:
|
||||
return _reject("apply: mismatched transport", secrets)
|
||||
|
||||
try:
|
||||
authorized = _apply_signed_history_ok(
|
||||
authorized = _apply_local_integrity_ok(
|
||||
repo, receipt, commit=commit, base_head=resolved_base
|
||||
)
|
||||
except Exception as exc:
|
||||
return _reject(f"apply: no signed history: {exc}", secrets)
|
||||
return _reject(f"apply: no local integrity record: {exc}", secrets)
|
||||
if not authorized:
|
||||
return _reject("apply: no signed history", secrets)
|
||||
return _reject("apply: no local integrity record", secrets)
|
||||
|
||||
merge = _git(repo, "merge", "--ff-only", "--quiet", commit, check=False)
|
||||
if merge.returncode != 0:
|
||||
@@ -2736,7 +2698,11 @@ def cmd_apply(repo: Path, receipt_path: Path) -> int:
|
||||
|
||||
|
||||
def cmd_admit(repo: Path) -> int:
|
||||
"""Accept staged host-managed paths, or paths exactly covered by one transport."""
|
||||
"""Accept staged host-managed paths, or paths exactly covered by one transport.
|
||||
|
||||
A pass is local integrity evidence (SELF), not GATE; invalid or missing
|
||||
local records still reject.
|
||||
"""
|
||||
repo = repo.resolve()
|
||||
secrets = _secret_set()
|
||||
try:
|
||||
@@ -2747,13 +2713,13 @@ def cmd_admit(repo: Path) -> int:
|
||||
return EXIT_SUCCESS
|
||||
head = _resolve_commit(repo, "HEAD")
|
||||
if head is None:
|
||||
return _reject("admit: no provenance", secrets)
|
||||
return _reject("admit: no local integrity record", secrets)
|
||||
history_text = ""
|
||||
history_path = _history_file(repo)
|
||||
if history_path is not None and history_path.is_file():
|
||||
history_text = history_path.read_text(encoding="utf-8")
|
||||
secrets = _secret_set(history_text)
|
||||
key = _read_attestation_key(repo)
|
||||
key = _read_local_integrity_key(repo)
|
||||
for record in _load_history_records(repo):
|
||||
if _transport_covers(
|
||||
repo, record, head=head, required=required, key=key
|
||||
@@ -2761,9 +2727,9 @@ def cmd_admit(repo: Path) -> int:
|
||||
print("admit: ok")
|
||||
return EXIT_SUCCESS
|
||||
except Exception as exc:
|
||||
return _reject(f"admit: no provenance: {exc}", secrets)
|
||||
return _reject(f"admit: no local integrity record: {exc}", secrets)
|
||||
listed = ", ".join(sorted(required))
|
||||
return _reject(f"admit: no provenance for {listed}", secrets)
|
||||
return _reject(f"admit: no local integrity record for {listed}", secrets)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,11 +5,14 @@ does not contact providers or launch real agent runs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from scripts.agent_exec_config import (
|
||||
ALLOWED_PLACEHOLDERS,
|
||||
@@ -23,6 +26,34 @@ _PROBE_TIMEOUT_SECONDS = 5
|
||||
MAX_UNIT_PACKET_BYTES = 262_144
|
||||
UNIT_PACKET_DISPLAY_MARKER = "<unit-packet>"
|
||||
|
||||
TERMINAL_STATUSES = (
|
||||
"completed",
|
||||
"failed",
|
||||
"blocked",
|
||||
"cancelled",
|
||||
"failed-unknown",
|
||||
"scope-expansion",
|
||||
)
|
||||
TERMINAL_RECORD_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"terminal_status",
|
||||
"summary",
|
||||
"changed_files",
|
||||
"evidence",
|
||||
"scope_expansion",
|
||||
],
|
||||
"properties": {
|
||||
"terminal_status": {"enum": list(TERMINAL_STATUSES)},
|
||||
"summary": {"type": "string"},
|
||||
"changed_files": {"type": "array", "items": {"type": "string"}},
|
||||
"evidence": {"type": "array", "items": {"type": "string"}},
|
||||
"scope_expansion": {"type": ["string", "null"]},
|
||||
},
|
||||
}
|
||||
# Bounds the backwards brace scan over one final message.
|
||||
_MAX_TERMINAL_TEXT_CHARS = 262_144
|
||||
|
||||
_PLACEHOLDER_MAP = {
|
||||
"${workspace}": "workspace",
|
||||
"${unit_packet}": "unit_packet",
|
||||
@@ -378,17 +409,177 @@ def _read_unit_packet_text(path: Path) -> str:
|
||||
|
||||
|
||||
def _build_bounded_unit_prompt(packet_text: str) -> str:
|
||||
schema = json.dumps(TERMINAL_RECORD_SCHEMA, separators=(",", ":"))
|
||||
return (
|
||||
"Implement exactly the supplied bounded implementation unit. "
|
||||
"Work only inside the current workspace. "
|
||||
"Leave all changes uncommitted. "
|
||||
"Do not expand scope beyond the unit packet. "
|
||||
"Return a structured final result matching the unit's required schema.\n\n"
|
||||
"--- TERMINAL RECORD ---\n\n"
|
||||
"End your final message with exactly one terminal record: a single-line "
|
||||
"JSON object that validates against this JSON Schema:\n"
|
||||
f"{schema}\n"
|
||||
"Use terminal_status completed only when the unit is fully implemented. "
|
||||
"Use blocked, cancelled, failed-unknown, failed, or scope-expansion "
|
||||
"otherwise. A missing or invalid terminal record fails the attempt.\n\n"
|
||||
"--- BOUNDED IMPLEMENTATION UNIT PACKET ---\n\n"
|
||||
f"{packet_text}"
|
||||
)
|
||||
|
||||
|
||||
def extract_terminal_record(text: str | None) -> dict[str, Any] | None:
|
||||
"""Return the last valid terminal record embedded in one final message."""
|
||||
if not isinstance(text, str) or not text:
|
||||
return None
|
||||
text = text[-_MAX_TERMINAL_TEXT_CHARS:]
|
||||
decoder = json.JSONDecoder()
|
||||
pos = text.rfind("{")
|
||||
while pos != -1:
|
||||
try:
|
||||
candidate, _ = decoder.raw_decode(text, pos)
|
||||
except json.JSONDecodeError:
|
||||
candidate = None
|
||||
record = _valid_terminal_record(candidate)
|
||||
if record is not None:
|
||||
return record
|
||||
pos = text.rfind("{", 0, pos)
|
||||
return None
|
||||
|
||||
|
||||
def _valid_terminal_record(obj: object) -> dict[str, Any] | None:
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
status = obj.get("terminal_status")
|
||||
if not isinstance(status, str):
|
||||
return None
|
||||
status = status.strip().replace("_", "-")
|
||||
if status not in TERMINAL_STATUSES:
|
||||
return None
|
||||
if not isinstance(obj.get("summary"), str):
|
||||
return None
|
||||
for key in ("changed_files", "evidence"):
|
||||
items = obj.get(key)
|
||||
if not isinstance(items, list) or any(not isinstance(i, str) for i in items):
|
||||
return None
|
||||
if "scope_expansion" not in obj or not (
|
||||
obj["scope_expansion"] is None or isinstance(obj["scope_expansion"], str)
|
||||
):
|
||||
return None
|
||||
record = dict(obj)
|
||||
record["terminal_status"] = status
|
||||
return record
|
||||
|
||||
|
||||
def decode_terminal_record(
|
||||
adapter: str,
|
||||
*,
|
||||
event_lines: Sequence[str | None],
|
||||
stdout_tail: str,
|
||||
result_text: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Decode the worker terminal record from the adapter's real output.
|
||||
|
||||
``event_lines`` are recent complete stdout lines that may carry a terminal
|
||||
envelope, oldest first; ``None`` marks an envelope line too large to keep.
|
||||
Event-stream adapters are read only from their final assistant/result
|
||||
envelope, so prompt echoes and tool output never count as a record.
|
||||
"""
|
||||
if adapter in ("cursor", "claude"):
|
||||
return extract_terminal_record(_cursor_final_text(event_lines))
|
||||
if adapter == "antigravity":
|
||||
return extract_terminal_record(_agy_final_text(event_lines, stdout_tail))
|
||||
if adapter == "pi":
|
||||
return extract_terminal_record(_pi_final_text(event_lines))
|
||||
record = extract_terminal_record(result_text)
|
||||
if record is None:
|
||||
record = extract_terminal_record(stdout_tail)
|
||||
return record
|
||||
|
||||
|
||||
def _event_objects(event_lines: Sequence[str | None]) -> list[dict[str, Any] | None]:
|
||||
"""Newest-first parsed events; ``None`` keeps the oversized-envelope marker."""
|
||||
out: list[dict[str, Any] | None] = []
|
||||
for line in reversed(event_lines):
|
||||
if line is None:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
out.append(obj)
|
||||
return out
|
||||
|
||||
|
||||
def _cursor_final_text(event_lines: Sequence[str | None]) -> str | None:
|
||||
for obj in _event_objects(event_lines):
|
||||
if obj is None:
|
||||
return None
|
||||
if obj.get("type") != "result":
|
||||
continue
|
||||
if obj.get("is_error") is not False or obj.get("subtype") != "success":
|
||||
return None
|
||||
text = obj.get("result")
|
||||
return text if isinstance(text, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _agy_final_text(
|
||||
event_lines: Sequence[str | None], stdout_tail: str
|
||||
) -> str | None:
|
||||
candidates = _event_objects(event_lines)
|
||||
try:
|
||||
whole = json.loads(stdout_tail.strip())
|
||||
except json.JSONDecodeError:
|
||||
whole = None
|
||||
if isinstance(whole, dict):
|
||||
candidates.append(whole)
|
||||
for obj in candidates:
|
||||
if obj is None:
|
||||
return None
|
||||
if obj.get("event") == "result":
|
||||
payload = obj.get("result")
|
||||
elif "event" not in obj and "response" in obj:
|
||||
payload = obj
|
||||
else:
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
if payload.get("is_error") or payload.get("error"):
|
||||
return None
|
||||
text = payload.get("response")
|
||||
return text if isinstance(text, str) else None
|
||||
return None
|
||||
|
||||
|
||||
def _pi_final_text(event_lines: Sequence[str | None]) -> str | None:
|
||||
for obj in _event_objects(event_lines):
|
||||
if obj is None:
|
||||
return None
|
||||
if obj.get("type") != "message_end":
|
||||
continue
|
||||
message = obj.get("message")
|
||||
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||||
continue
|
||||
if message.get("stopReason") in ("error", "aborted"):
|
||||
return None
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, list):
|
||||
return None
|
||||
return "".join(
|
||||
block["text"]
|
||||
for block in content
|
||||
if isinstance(block, dict)
|
||||
and block.get("type") == "text"
|
||||
and isinstance(block.get("text"), str)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _expand_argv(
|
||||
items: tuple[str, ...],
|
||||
values: dict[str, str],
|
||||
@@ -480,11 +671,15 @@ def _sanitize_display(
|
||||
|
||||
__all__ = [
|
||||
"MAX_UNIT_PACKET_BYTES",
|
||||
"TERMINAL_RECORD_SCHEMA",
|
||||
"TERMINAL_STATUSES",
|
||||
"UNIT_PACKET_DISPLAY_MARKER",
|
||||
"AdapterProbe",
|
||||
"AdapterSpec",
|
||||
"BUILTIN_SPECS",
|
||||
"Invocation",
|
||||
"build_invocation",
|
||||
"decode_terminal_record",
|
||||
"extract_terminal_record",
|
||||
"probe_adapter",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地硬闸(pre-commit):集成分支直提拒绝 + 分支命名。权威准入在 PR(baseline.md:26)。
|
||||
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
case "$branch" in
|
||||
main|master)
|
||||
@@ -11,4 +11,6 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
exit 1
|
||||
}
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
|
||||
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/agent_exec.py" admit --repo "$root"
|
||||
|
||||
Reference in New Issue
Block a user