Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ed889cf
OpenHands resolver: owned-path allowlist gate on the exact pushed com…
Oct 5, 2026
8ce7136
OpenHands resolver: enable the public driver behind a start-up self-t…
Oct 5, 2026
ee78a71
registry: register the resolver owned-path gate, enablement and fail-…
Oct 5, 2026
ffda8b1
OpenHands resolver gate: hardening from the two Opus reads (gitmodule…
Oct 5, 2026
3aa5200
Hot-file protocol: reset manifests/evidence.json to the merge base be…
Oct 5, 2026
56fced8
Reapply this branch's manifests/evidence.json edits on the merge base…
Oct 5, 2026
9c310ac
OpenHands resolver gate r3: git-semantic dot files and instruction fi…
Oct 5, 2026
1f9f050
Hot-file protocol: reset manifests/evidence.json to the merge base be…
Oct 5, 2026
656f263
Reapply this branch's manifests/evidence.json edits on the merge base…
Oct 5, 2026
921d3d1
Resolver owned-path gate r4: refuse the real Codex AGENTS template an…
Oct 5, 2026
20ff609
Hot-file protocol: reset manifests/evidence.json to the merge base be…
Oct 5, 2026
0e2610d
Reapply this branch's manifests/evidence.json edits on the merge base…
Oct 5, 2026
c76fe6e
Resolver owned-path gate r5: refuse adoption/agents/ as instruction f…
Oct 5, 2026
d5d7926
Hot-file protocol: reset manifests/evidence.json to the merge base be…
Oct 5, 2026
9b8a37b
Reapply this branch's manifests/evidence.json edits on the merge base…
Oct 5, 2026
e2575a1
Resolver gate receipt: record the coordinator's writable-lock rerun o…
Oct 5, 2026
0f215e4
Hot-file protocol: reset manifests/evidence.json to the merge base be…
Oct 5, 2026
87f27ea
Reapply this branch's manifests/evidence.json edits on the merge base…
Oct 5, 2026
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
167 changes: 143 additions & 24 deletions blueprints/runtime-workers/openhands/RESOLVER.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion blueprints/runtime-workers/openhands/receipt.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,10 @@ def _matching(value, pattern):


# The trusted pre-push gate's records (resolver/push_gate.py, journaled by GhHarness.gates).
# The rules a path can break, and zizmor's audit names (docs.zizmor.sh/audits).
# Named protected-path categories, and zizmor's audit names (docs.zizmor.sh/audits).
# Ownership/mode/path-safety/compiled-artifact classes in push_gate.UNNAMED_PATH_RULES
# stay unnamed even if marked known: keep their codes and counts, never their paths.
# The gate's source-derived protocol test checks the named/unnamed partition.
PUSH_GATE_RULES = frozenset({"github", "codeowners", "gate_code", "workflow_policy_test", "ci_named", "ci_import",
"ci_read", "ci_discovered", "ci_local_action", "pr_text_interpolation", "zizmor_finding",
"unresolved_read"})
Expand Down Expand Up @@ -298,6 +301,10 @@ def push_gate_summary(records):
"trusted_commit": _matching(record.get("trusted_commit"), HEX40),
"reasons": sorted({reason for reason in record.get("reasons") or [] if _matching(reason, REASON_CODE)})
if isinstance(record.get("reasons"), list) else [],
"changed_path_count": record.get("changed_path_count")
if type(record.get("changed_path_count")) is int and record["changed_path_count"] >= 0 else None,
"owned_path_count": record.get("owned_path_count")
if type(record.get("owned_path_count")) is int and record["owned_path_count"] >= 0 else None,
"paths": named,
"advisory_gate_reads": advisory_gate_reads_summary(record.get("advisory_gate_reads")),
"unnamed_paths": len(entries) - len(named) + (omitted if type(omitted) is int and omitted > 0 else 0),
Expand Down
80 changes: 69 additions & 11 deletions blueprints/runtime-workers/openhands/resolver.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Deterministic host driver for the OpenHands PR resolver.

2026-10-04: the public driver is disabled until the owned-path allowlist gate lands.
The stages below describe dormant code, retained for the later enablement PR.
2026-10-04: the public driver requires the owned-path gate's startup negative control.
The handlers below proceed after that control and retain their existing stage gates.

Stage 1 built the driver's core and a CLI that exercises it with fakes. Stage 2
wires it into host.run and dispatch: `run` performs one attempt end to end
Expand Down Expand Up @@ -65,7 +65,10 @@ def _load(name):
patch_policy = _load("patch_policy")
gh_harness = _load("gh_harness")
outgoing_guard = _load("outgoing_guard")
push_gate = _load("push_gate")
try:
push_gate = _load("push_gate")
except BaseException: # An unavailable gate reaches the public exit-3 refusal below.
push_gate = None


# -- Unit 1: issue selection and the untrusted-input block (plan section 2, steps 1 and 4)
Expand Down Expand Up @@ -355,7 +358,26 @@ def resolver_instruction(selected, *, task, owned_paths, new_boundary=new_bounda
"flags. If the fix needs such a change, including a new or changed test, change nothing: "
"stop and report which file would need to change and why.\n"
"- Static read inventories are monitoring only; they authorize no path and refuse no change. "
"The resolver entry point stays disabled until the owned-path allowlist gate lands.\n"
"The trusted push gate independently checks the owned paths on the exact commit and refuses "
"every unowned path, including either end of a rename; symlinks, gitlinks, mode/type changes, "
"unsafe path components (empty, . or .. components), absolute paths, .git or its HFS/NTFS aliases, "
".gitmodules and its aliases; and a casefold or NFC/NFKC alias of a protected path or a collision "
"with another changed path.\n"
"- Even inside owned paths, compiled or bytecode artifacts (compiled_module_artifact) are refused "
"anywhere: every __pycache__ component and .pyc, .pyo, .so, .pyd, .dylib or .dll suffix. "
"Git semantic dot files (git_semantic_file) are refused at any depth: .git* names including "
".gitattributes, .gitignore and .gitconfig, plus .mailmap; .git, .gitmodules and .github keep their "
"existing refusals. Instruction files (instruction_file) are also refused by basename at any depth "
"or by directory prefix: AGENTS.md, "
"AGENTS.override.md, AGENTS.template.md, codex.AGENTS.template.md, CLAUDE.md, CLAUDE.local.md, "
"GEMINI.md, SKILL.md, RTK.md, codex-user-instructions.md, claude-user-instructions.md, "
"token-lanes-block.md, token-lanes-block.builder.md, token-lanes-block.researcher.md, "
"token-lanes-block.reviewer.md, token-lanes-block.scout.md, token-lanes-block.verifier.md "
"and adoption/agents/. The prefix covers Claude definitions, Codex roles and their pin files. "
"Case/Unicode and NTFS trailing-dot, "
"space or stream forms are refused too; instruction and Git names also discard Git's HFS-ignorable "
"characters. If the fix needs such a change, change nothing: stop and report "
"which file would need to change and why.\n"
"- There is no network: only the model endpoint is reachable. Do not fetch the issue, "
"install packages, push or open a pull request; the host does that from your patch.\n\n"
"Workflow:\n"
Expand Down Expand Up @@ -1279,7 +1301,7 @@ def __init__(self, *, number, title, base_sha, branch, owned_paths, lane, instru
if not isinstance(base_sha, str) or not SHA.fullmatch(base_sha):
raise ValueError("base_sha_required")
self.number, self.title, self.base_sha, self.branch = number, title, base_sha, branch
self.owned_paths = patch_policy.normalize_owned(owned_paths)
self.owned_paths = tuple(patch_policy.normalize_owned(owned_paths))
self.lane, self.instruction, self.run_id = lane, instruction, run_id
self.gh, self.git, self.gitleaks, self.gitleaks_config = gh, git, gitleaks, gitleaks_config
# The trusted pre-push gate: by default push_gate.PushGate from this checkout's resolver/
Expand All @@ -1293,7 +1315,7 @@ def __init__(self, *, number, title, base_sha, branch, owned_paths, lane, instru
self.harness = self.guard = self.clone = self.pr = self.clone_home = None

def identity(self):
return {"issue": self.number, "base_sha": self.base_sha, "owned_paths": self.owned_paths, "lane": self.lane,
return {"issue": self.number, "base_sha": self.base_sha, "owned_paths": list(self.owned_paths), "lane": self.lane,
"run_id": self.run_id,
"instruction_sha256": hashlib.sha256(self.instruction.encode("utf-8")).hexdigest(),
"reviewer_argv_sha256": self.reviewer_argv_sha256}
Expand All @@ -1315,7 +1337,7 @@ def _session(self, result):
gate = self.gate if self.gate is not None else push_gate.PushGate(git=self.git, zizmor=self.zizmor)
self.harness = gh_harness.GhHarness(self.gh, git=self.git, base_env=self.base_env,
workdir=gh_harness.private_workdir(str(private)), runner=self.runner,
guard=self.guard, push_gate=gate)
guard=self.guard, push_gate=gate, owned_paths=self.owned_paths)
return private

def _commit_message(self):
Expand Down Expand Up @@ -1838,14 +1860,50 @@ def build_parser():
return parser


DISABLED_MESSAGE = ("resolver disabled until the owned-path allowlist gate lands "
DISABLED_MESSAGE = ("resolver disabled: owned-path allowlist gate self-test failed "
"(docs/decisions/2026-09-28-openhands-resolver-isolation.md)")


def _startup_gate_self_test():
"""git's native hash-object/mktree/commit-tree fixture; no network or container.

A real unowned addition must be refused specifically as unowned_path, rather than
passing because a different check (trusted ancestry or unavailable zizmor) refused.
The fixture's allowlist is constant; no argument or environment chooses its scope.
"""
if push_gate is None:
return False
try:
git = shutil.which("git", path="/usr/bin:/bin")
with tempfile.TemporaryDirectory(prefix="resolver-gate-self-test-") as directory:
def run(*args, input_bytes=None):
return subprocess.run([git, "-C", directory, "-c", "user.name=Resolver gate fixture",
"-c", "user.email=gate-fixture@example.invalid", *args],
input=input_bytes, check=True, capture_output=True, timeout=10,
env=dict(push_gate.GIT_ENV)).stdout.decode("ascii").strip()

run("init", "-q", "--template=", "-b", "main")
empty = run("mktree", input_bytes=b"")
base = run("commit-tree", empty, "-m", "base fixture")
blob = run("hash-object", "-w", "--stdin", input_bytes=b"negative control\n")
tree = run("mktree", input_bytes=f"100644 blob {blob}\tunowned.txt\n".encode("ascii"))
head = run("commit-tree", tree, "-p", base, "-m", "unowned fixture")
with contextlib.redirect_stderr(io.StringIO()):
record = push_gate.PushGate(git=git, zizmor=None).check(
directory, base=base, head=head, owned_paths=("owned.txt",))
return (record.get("status") == "fail" and record.get("reasons") == ["unowned_path"]
and record.get("commit") == head and record.get("base") == base
and record.get("changed_path_count") == 1 and record.get("owned_path_count") == 1)
except BaseException:
return False


def main(argv=None, *, session_key=None, **injected):
"""Public entry point is disabled before parsing, network, push or model calls."""
print(DISABLED_MESSAGE, file=sys.stderr)
return 3
"""A successful import and native unowned-commit negative control precede parsing."""
if not _startup_gate_self_test():
print(DISABLED_MESSAGE, file=sys.stderr)
return 3
return _dormant_main(argv, session_key=session_key, **injected)


def _dormant_main(argv=None, *, session_key=None, **injected):
Expand Down
4 changes: 2 additions & 2 deletions blueprints/runtime-workers/openhands/resolver/gate_reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

The static evaluator's files, prefixes, globs, computed execution/unresolved locations
and unclassified entries are advisory. They provide no protection, refuse no commit and
make no completeness claim. The resolver entry point is disabled pending a separate
owned-path allowlist gate. Known gaps and superseded enforcement claims are recorded in
make no completeness claim. The public resolver proceeds after the separate owned-path
gate's startup negative control succeeds. Known gaps and superseded claims are recorded in
RESOLVER.md and docs/decisions/2026-09-28-openhands-resolver-isolation.md.

The existing bounded evaluator is retained unchanged: literals and imported containers,
Expand Down
15 changes: 13 additions & 2 deletions blueprints/runtime-workers/openhands/resolver/gh_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,13 +664,16 @@ class GhHarness:
"""

def __init__(self, gh, *, base_env, workdir, git="/usr/bin/git", runner=subprocess.run, guard=None,
timeout=600, push_gate=None):
timeout=600, push_gate=None, owned_paths=None):
self.gh = _executable(gh, "unsafe_gh_path")
self.git = _executable(git, "unsafe_git_path")
self.workdir = check_workdir(workdir)
self.env = child_env(base_env, gh_path=self.gh, workdir=self.workdir)
self.runner, self.guard, self.timeout = runner, guard, timeout
self.push_gate = push_gate
# Coordinator-owned host memory from ResolverAttempt._session; never an
# export, issue, environment value or reread of resolver-identity.json.
self.owned_paths = tuple(owned_paths) if isinstance(owned_paths, (list, tuple)) else None
self.writes, self.gates = [], []
self._gated = set()

Expand Down Expand Up @@ -749,10 +752,18 @@ def push(self, clone, branch, *, base, head, agent_trees=()):
gate_file = getattr(module, "__file__", None)
if not isinstance(gate_file, str) or _inside(gate_file, trees):
record = {"commit": head, "base": base, "status": "fail", "reasons": ["gate_inside_agent_tree"],
"changed_path_count": None, "owned_path_count": None,
"paths": [], "trusted_commit": None, "protected": None,
"zizmor": {"version": None, "findings": None, "failing": []}}
else:
record = self.push_gate.check(clone, base=base, head=head, agent_trees=tuple(agent_trees))
try:
record = self.push_gate.check(clone, base=base, head=head, owned_paths=self.owned_paths,
agent_trees=tuple(agent_trees))
except BaseException: # A broken/interrupted gate journals a refusal and never pushes.
record = {"commit": head, "base": base, "status": "fail", "reasons": ["gate_exception"],
"changed_path_count": None, "owned_path_count": None, "paths": [],
"trusted_commit": None, "protected": None,
"zizmor": {"version": None, "findings": None, "failing": []}}
self.gates.append(dict(record) if isinstance(record, dict) else {"commit": head, "status": "fail",
"reasons": ["gate_record_invalid"]})
if not (isinstance(record, dict) and record.get("status") == "pass" and record.get("commit") == head
Expand Down
Loading
Loading