From aef949361a14c0b61437e3ab9d772f93bc16ac67 Mon Sep 17 00:00:00 2001 From: Pengfei Hu Date: Fri, 2 Oct 2026 23:26:05 -0700 Subject: [PATCH] feat(openshell): bind selected policies to verification inputs --- docs/openshell-support.md | 26 ++- .../cli/verify/host_comparison.py | 15 +- src/agents_shipgate/cli/verify/host_tree.py | 2 + src/agents_shipgate/core/current_control.py | 13 +- src/agents_shipgate/core/host_grants.py | 38 +++- src/agents_shipgate/core/openshell_inputs.py | 34 ++++ src/agents_shipgate/core/static_inputs.py | 21 +++ .../core/verification_identity.py | 26 ++- tests/test_openshell_inputs.py | 173 ++++++++++++++++++ 9 files changed, 334 insertions(+), 14 deletions(-) create mode 100644 src/agents_shipgate/core/openshell_inputs.py create mode 100644 tests/test_openshell_inputs.py diff --git a/docs/openshell-support.md b/docs/openshell-support.md index 48d6ed28..2200e396 100644 --- a/docs/openshell-support.md +++ b/docs/openshell-support.md @@ -21,8 +21,8 @@ This registration is an Agents Shipgate format. OpenShell does not discover policies from this filename. Paths are normalized, relative to the audited workspace root, even when a registration is nested. Arbitrary filenames are accepted. Each registration selects 1–64 distinct paths, with at most 64 policy -references across the workspace, including repeated selections. Globs, URLs, absolute -paths and `..` are rejected. A policy is read only when a registration selects +references across the workspace, including repeated selections. Paths select exact +filenames; glob expansion is unavailable. URLs, absolute paths and `..` are rejected. A policy is read only when a registration selects it. The detection census recognizes the registration filename without reading the policy. It never classifies every YAML file as an OpenShell policy. @@ -124,3 +124,25 @@ current inventories/baselines/drift use v0.8. The checked-in [example](../samples/openshell/sandbox.yaml) is selected by `samples/openshell/.shipgate/openshell.json` when auditing this repository root. For another workspace, copy the policy and register its new local path. + +## Selected input identity + +Each comparison reads registrations and selected policies independently from +its base and head tree. Arbitrary policy filenames, selection-only edits, +deletions and in-tree link chains use the same bounded archive closure as +other host dependencies. An unread selected policy makes the comparison +incomplete; it never means an empty policy. + +Verification binds regular document bytes, link-target text, and named missing +inputs to the existing plan and receipt lifecycle. Ignored policies participate +in currency checks. Retargeting a link, replacing its type or changing a consumed +policy invalidates current control, even when Git reports no changed files. +The existing `input_script_blobs` field carries these host dependencies too; +`source: generated` identifies derived UTF-8 link-target text, while +`source: worktree` identifies regular bytes. Plan dependency provenance records +links separately. Old plans without a links collection remain readable. + +Credential-shaped input paths cannot identify published bytes after redaction. +They become named unsupported, unconfirmable inputs, without publishing raw +paths or link-target digests. Static identity establishes which documents were +read, not whether an effective export is fresh or enforced by a running sandbox. diff --git a/src/agents_shipgate/cli/verify/host_comparison.py b/src/agents_shipgate/cli/verify/host_comparison.py index 7af73ebd..8738d3eb 100644 --- a/src/agents_shipgate/cli/verify/host_comparison.py +++ b/src/agents_shipgate/cli/verify/host_comparison.py @@ -218,18 +218,25 @@ def changed_inputs() -> ChangedInputs: if head is None: from agents_shipgate.schemas.verification_identity import VerificationBlob + reads = {**head_snapshot.cache.hook_script_reads, **head_snapshot.cache.openshell_input_reads} result.input_script_blobs = [ VerificationBlob( path=path, sha256="sha256:" + facts["sha256"], - size_bytes=facts["size_bytes"], source="worktree", + size_bytes=facts["size_bytes"], source=facts.get("source", "worktree"), ) - for path, facts in sorted(head_snapshot.cache.hook_script_reads.items()) + for path, facts in sorted(reads.items()) if facts.get("sha256") is not None ] - result.input_script_absent_paths = sorted(head_snapshot.cache.hook_script_absences) + result.input_script_absent_paths = sorted( + head_snapshot.cache.hook_script_absences | head_snapshot.cache.openshell_input_absences + ) result.input_script_unconfirmable_paths = sorted( - path for path, facts in head_snapshot.cache.hook_script_reads.items() + {path for captured in ( + head_snapshot.cache.hook_script_reads, + head_snapshot.cache.openshell_input_reads, + ) for path, facts in captured.items() if facts.get("limit") not in {None, "missing_input"} + } ) result_coverage = result.coverage mentions_unread = result_coverage is not None and ( diff --git a/src/agents_shipgate/cli/verify/host_tree.py b/src/agents_shipgate/cli/verify/host_tree.py index 89f69064..b07c3e03 100644 --- a/src/agents_shipgate/cli/verify/host_tree.py +++ b/src/agents_shipgate/cli/verify/host_tree.py @@ -43,6 +43,8 @@ def dependencies(tree: Path) -> tuple[Path, Callable[[str], bool]] | None: # this reader deliberately never consumes the target's bytes. and item.get("limit") not in {"redacted_dependency_path", "symlink_input"} } + found.update(snapshot.cache.openshell_selected_paths) + found = {path for path in found if not is_boundary_surface_path(path)} if not found: read["snapshot"] = snapshot return None diff --git a/src/agents_shipgate/core/current_control.py b/src/agents_shipgate/core/current_control.py index 8d48692b..6af78217 100644 --- a/src/agents_shipgate/core/current_control.py +++ b/src/agents_shipgate/core/current_control.py @@ -680,7 +680,7 @@ def read_current_control( validated = _validate_bound_artifacts( out_dir, pointer, - capture=set(capture) | {"verification_plan", RECEIPT_ARTIFACT_KEY}, + capture=set(capture) | {"verification_plan", "verifier", RECEIPT_ARTIFACT_KEY}, ) except CurrentControlUnavailable as mismatch: # A run that republished mid-read moves the pointer too. Retry that @@ -993,7 +993,16 @@ def _validate_hook_script_currency( reader = cache.reader_for(live.root) for item in comparison.get("input_script_blobs", []): bound = VerificationBlob.model_validate(item) - observed = capture_hook_script(reader, bound.path) + if bound.source == "generated": + from agents_shipgate.core.openshell_inputs import capture_openshell_input + + observed = capture_openshell_input(reader, bound.path, absent_paths=set()) + if observed.get("source") != "generated": + raise ValueError("selected link no longer has its captured type") + elif bound.source == "worktree": + observed = capture_hook_script(reader, bound.path) + else: + raise ValueError("invalid live dependency source") if ( observed.get("limit") is not None or "sha256:" + str(observed.get("sha256")) != bound.sha256 diff --git a/src/agents_shipgate/core/host_grants.py b/src/agents_shipgate/core/host_grants.py index d9b65a09..9eaf1c19 100644 --- a/src/agents_shipgate/core/host_grants.py +++ b/src/agents_shipgate/core/host_grants.py @@ -31,6 +31,7 @@ BOUNDARY_ADAPTERS, CLAUDE_PLUGIN_DEFAULT_HOOKS, CLAUDE_PLUGIN_MARKETPLACE, + boundary_adapters_for_path, is_claude_plugin_manifest_path, is_claude_plugin_marketplace_path, is_claude_plugin_reference_path, @@ -79,6 +80,7 @@ policy_field_paths, ) from agents_shipgate.core.openshell_compare import compare_openshell_grants +from agents_shipgate.core.openshell_inputs import capture_openshell_input from agents_shipgate.core.permission_lattice import ( exec_equivalent_argument, permission_pairing_group, @@ -168,6 +170,9 @@ class HostStaticParseCache: reference_workspace: Path | None = None hook_script_reads: dict[str, dict[str, Any]] = field(default_factory=dict) hook_script_absences: set[str] = field(default_factory=set) + openshell_selected_paths: set[str] = field(default_factory=set) + openshell_input_reads: dict[str, dict[str, Any]] = field(default_factory=dict) + openshell_input_absences: set[str] = field(default_factory=set) _reads: dict[tuple[str, str], tuple[str | None, str | None]] = field( default_factory=dict ) @@ -3829,6 +3834,35 @@ def record(artifact: dict[str, Any]) -> None: budget.artifact_ids.add(artifact["artifact_id"]) artifacts.append(artifact) + reader = cache.reader_for(root) + dependencies = (label, *hops) + cache.openshell_selected_paths.update(dependencies) + if any(public_host_path(dependency) != dependency for dependency in dependencies): + # A redacted path cannot identify exact bytes. Do not publish the + # original path or a digest of a credential-shaped link target. + shown = public_host_path(label) + cache.openshell_input_reads[shown] = {"limit": "redacted_input_path"} + from agents_shipgate.core.static_inputs import active_static_input_snapshot + + snapshot = active_static_input_snapshot() + if snapshot is not None and snapshot.root == root: + snapshot.mark_unconfirmable_dependency(root / shown) + record(_artifact( + host="openshell", scope="repository", source=label, kind=kind, + status="unsupported", resolved_through=hops, + )) + issues.append(_inventory_issue( + kind="unsupported", host="openshell", source=label, + message="Selected OpenShell input paths cannot be bound after credential redaction", + blocking=True, + )) + return None + for dependency in dependencies: + if dependency not in cache.openshell_input_reads: + cache.openshell_input_reads[dependency] = capture_openshell_input( + reader, dependency, absent_paths=cache.openshell_input_absences, + ) + text, error = cache.read(read_path, containment_root=root) if error: record(_artifact( @@ -5371,7 +5405,9 @@ def note_unusable_selected_hooks(data: Any, *, source: str) -> None: collected_claude_sources: set[str] = set() openshell_budget = OpenShellCollectionBudget() for path, source, host, kind, resolved_through in repository_paths: - if host == "openshell": + if host == "openshell" and any( + adapter.id == "openshell" for adapter in boundary_adapters_for_path(source) + ): _collect_openshell( path=path, source=source, root=root, cache=cache, artifacts=artifacts, grants=grants, issues=issues, diff --git a/src/agents_shipgate/core/openshell_inputs.py b/src/agents_shipgate/core/openshell_inputs.py new file mode 100644 index 00000000..745e173c --- /dev/null +++ b/src/agents_shipgate/core/openshell_inputs.py @@ -0,0 +1,34 @@ +"""Bind selected document and link bytes to the existing dependency lifecycle.""" +from __future__ import annotations + +import hashlib +from pathlib import Path + +from agents_shipgate.core.hook_script_capture import capture_hook_script +from agents_shipgate.core.static_inputs import active_static_input_snapshot +from agents_shipgate.core.trust_roots import IdentityBoundReadSession, IdentityReadBudgetExceeded + + +def capture_openshell_input(reader: IdentityBoundReadSession, path: str, *, absent_paths: set[str]) -> dict: + """Regular documents use the shared file capture; links bind target text. + + A `generated` VerificationBlob is the derived UTF-8 link-target text, not + a regular file. Its source discriminator makes type replacement stale. + Nothing follows a link here; the host reader separately resolves it. + """ + relative = Path(path) + snapshot = active_static_input_snapshot() + if snapshot is not None and snapshot.root != reader.root: + snapshot = None + try: + if reader.directory_entry_kind(relative) != "symlink": + return {**capture_hook_script(reader, path, absent_paths=absent_paths), "source": "worktree"} + raw = reader.link_target(relative).encode("utf-8") + if snapshot is not None: + snapshot.bind_dependency_link(reader.root / relative, raw) + return {"sha256": hashlib.sha256(raw).hexdigest(), "size_bytes": len(raw), "limit": None, "source": "generated"} + except IdentityReadBudgetExceeded: + raise + except (OSError, ValueError, UnicodeError): + # A missing component still belongs to the generic absence mechanism. + return {**capture_hook_script(reader, path, absent_paths=absent_paths), "source": "worktree"} diff --git a/src/agents_shipgate/core/static_inputs.py b/src/agents_shipgate/core/static_inputs.py index dd4d0ce4..ab5ad2ff 100644 --- a/src/agents_shipgate/core/static_inputs.py +++ b/src/agents_shipgate/core/static_inputs.py @@ -48,6 +48,7 @@ def __init__( } self._entries: dict[Path, bytes] = {} self._dependency_paths: set[Path] = set() + self._dependency_links: dict[Path, bytes] = {} self._absent_dependency_paths: set[Path] = set() self._present_dependency_paths: set[Path] = set() self._unconfirmable_dependency_paths: set[Path] = set() @@ -153,6 +154,26 @@ def bind_dependency_absence(self, path: Path) -> bool: def dependency_paths(self) -> list[Path]: return sorted(self._dependency_paths) + def bind_dependency_link(self, path: Path, expected: bytes | None = None) -> bytes: + """Bind an exact selected symbolic-link object without following it.""" + if self._finished: + raise ValueError("static input snapshot is already finalized") + key, relative = self._key(path) + session, relative = self._session_for(key) + raw = session.link_target(relative).encode("utf-8") + if expected is not None and raw != expected: + self.mark_unconfirmable_dependency(key) + raise ValueError("selected link moved between identity-bound reads") + previous = self._dependency_links.get(key) + if previous is not None and previous != raw: + self.mark_unconfirmable_dependency(key) + raise ValueError("selected link changed within the snapshot") + self._dependency_links[key] = raw + return raw + + def dependency_links(self) -> dict[Path, bytes]: + return dict(self._dependency_links) + def absent_dependency_paths(self) -> list[Path]: return sorted(self._absent_dependency_paths) diff --git a/src/agents_shipgate/core/verification_identity.py b/src/agents_shipgate/core/verification_identity.py index d5043091..a2327dcb 100644 --- a/src/agents_shipgate/core/verification_identity.py +++ b/src/agents_shipgate/core/verification_identity.py @@ -205,7 +205,7 @@ def build_verification_plan( normalized_options["input_directories"] = snapshot.input_directory_identity( source="git_blob" if archived_head else "worktree", ) - if snapshot is not None and (snapshot.dependency_paths() or snapshot.absent_dependency_paths() or snapshot.present_dependency_paths() or snapshot.unconfirmable_dependency_paths()): + if snapshot is not None and (snapshot.dependency_paths() or snapshot.dependency_links() or snapshot.absent_dependency_paths() or snapshot.present_dependency_paths() or snapshot.unconfirmable_dependency_paths()): normalized_options["dependency_inputs"] = { "files": sorted( [{"path": path.relative_to(input_root).as_posix(), @@ -227,6 +227,11 @@ def build_verification_plan( for path in snapshot.unconfirmable_dependency_paths() ), } + if snapshot.dependency_links(): + normalized_options["dependency_inputs"]["links"] = sorted( + [{"path": path.relative_to(input_root).as_posix(), "sha256": sha256_bytes(raw), "size_bytes": len(raw)} + for path, raw in snapshot.dependency_links().items()], key=lambda row: row["path"], + ) normalized_options["plugins_enabled"] = effective_plugins_enabled overlay_paths = sorted( set(changed_files if worktree_overlay_paths is None else worktree_overlay_paths) @@ -1147,14 +1152,18 @@ def validate_dependency_inputs(plan: VerificationPlan, *, root: Path, snapshot=N declaration = plan.inputs.options.get("dependency_inputs") if declaration is None: return - if not isinstance(declaration, dict) or set(declaration) != {"files", "absent_paths", "present_paths", "unconfirmable_paths"}: + if not isinstance(declaration, dict) or set(declaration) not in ( + {"files", "absent_paths", "present_paths", "unconfirmable_paths"}, + {"files", "links", "absent_paths", "present_paths", "unconfirmable_paths"}, + ): raise ValueError("invalid dependency input identity") files, absent, present = declaration["files"], declaration["absent_paths"], declaration["present_paths"] unconfirmable = declaration["unconfirmable_paths"] - if not all(isinstance(value, list) for value in (files, absent, present, unconfirmable)): + links = declaration.get("links", []) + if not all(isinstance(value, list) for value in (files, links, absent, present, unconfirmable)): raise ValueError("invalid dependency input identity") paths = [] - for row in files: + for row in [*files, *links]: if not isinstance(row, dict) or set(row) != {"path", "sha256", "size_bytes"}: raise ValueError("invalid dependency file identity") digest = row["sha256"] @@ -1164,7 +1173,7 @@ def validate_dependency_inputs(plan: VerificationPlan, *, root: Path, snapshot=N or type(row["size_bytes"]) is not int or row["size_bytes"] < 0): raise ValueError("invalid dependency file identity") paths.append(row["path"]) - for values in (paths, absent, present, unconfirmable): + for values in ([row["path"] for row in files], [row["path"] for row in links], absent, present, unconfirmable): if any( not isinstance(path, str) or not path or Path(path).is_absolute() or ".." in Path(path).parts or Path(path).as_posix() != path @@ -1174,6 +1183,9 @@ def validate_dependency_inputs(plan: VerificationPlan, *, root: Path, snapshot=N raise ValueError("dependency input escapes supplied root") if values != sorted(set(values)): raise ValueError("dependency input paths must be sorted and unique") + # File and link identities each have canonical order; their union is unique. + if len(paths) != len(set(paths)): + raise ValueError("dependency has conflicting file/link identities") if (set(paths) | set(present)) & set(absent): raise ValueError("dependency input is both present and absent") if unconfirmable: @@ -1190,6 +1202,10 @@ def validate_dependency_inputs(plan: VerificationPlan, *, root: Path, snapshot=N data = snapshot.read_bytes(root.resolve() / row["path"]) if len(data) != row["size_bytes"] or sha256_bytes(data) != row["sha256"]: raise ValueError("dependency input changed since verification") + for row in links: + data = snapshot.bind_dependency_link(root.resolve() / row["path"]) + if len(data) != row["size_bytes"] or sha256_bytes(data) != row["sha256"]: + raise ValueError("dependency link changed since verification") for path in absent: if not snapshot.bind_dependency_absence(root.resolve() / path): raise ValueError("dependency lookup candidate appeared since verification") diff --git a/tests/test_openshell_inputs.py b/tests/test_openshell_inputs.py new file mode 100644 index 00000000..133adf99 --- /dev/null +++ b/tests/test_openshell_inputs.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from test_current_control import _live, _verify +from test_current_control import repo as repo # noqa: F401 +from test_partial_host_comparison import _git, _repository +from typer.testing import CliRunner + +from agents_shipgate.cli.main import app +from agents_shipgate.cli.verify.host_comparison import compare_host_refs +from agents_shipgate.core.current_control import CurrentControlUnavailable, read_current_control +from agents_shipgate.core.host_grants import HostStaticParseCache, build_host_boundary_snapshot + +REGISTRATION = ".shipgate/openshell.json" +POLICY = """version: 1 +filesystem_policy: {include_workdir: false, read_only: [/data]} +network_policies: + api: + binaries: [{path: /usr/bin/client}] + endpoints: [{host: api.example.com, port: 443, protocol: rest, access: read-only, enforcement: enforce}] +""" + + +def selection(path="arbitrary.rules"): + return {"version": 1, "runtime_version": "0.1.2", "policies": [{"path": path, "role": "authored"}]} + + +def comparison(root, *, head=None): + result = compare_host_refs(workspace=root, base="main", head=head, auto_base=False, config_relative=Path("shipgate.yaml")) + assert result is not None + return result + + +def register(root, path="arbitrary.rules"): + registration = root / REGISTRATION + registration.parent.mkdir(parents=True, exist_ok=True) + registration.write_text(json.dumps(selection(path))) + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(POLICY) + return target + + +def test_committed_arbitrary_policy_uses_each_tree_even_with_dirty_checkout(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY}, + {"arbitrary.rules": POLICY.replace("enforcement: enforce", "enforcement: audit")}) + (root / "arbitrary.rules").write_text(POLICY) + committed = comparison(root, head="HEAD") + assert committed.comparison_status == "comparable" + row, = [row for row in committed.rows if row.subject.startswith("openshell")] + assert row.expands and row.direction == "widened" + assert not [row for row in comparison(root).rows if row.subject.startswith("openshell")] + + +def test_selection_only_change_is_visible_even_with_identical_bytes(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection("one"), "one": POLICY, "two": POLICY}, + {REGISTRATION: selection("two")}) + result = comparison(root, head="HEAD") + rows = [row for row in result.rows if row.subject.startswith("openshell")] + assert len(rows) == 2 and {row.subject for row in rows} == {"openshell one", "openshell two"} + assert all("selected document added or removed" in row.why for row in rows) + + +def test_deleted_selected_policy_is_unread_not_an_empty_policy(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY}, {"README.md": "change"}) + (root / "arbitrary.rules").unlink() + result = comparison(root) + assert result.comparison_status == "incomparable" and not result.rows + assert "arbitrary.rules" in result.input_script_absent_paths + + +def test_selected_link_chain_materializes_target_bytes_and_retains_hops(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection("policy-link"), "actual": POLICY}, + {"actual": POLICY.replace("enforcement: enforce", "enforcement: audit")}, + links={"policy-link": "middle", "middle": "actual"}) + result = comparison(root, head="HEAD") + assert result.comparison_status == "comparable" + assert any(row.expands and row.subject == "openshell policy-link" for row in result.rows) + snapshot = build_host_boundary_snapshot(root) + artifact = next(item for item in snapshot.inventory["artifacts"] if item["kind"] == "openshell_policy") + assert artifact["resolved_through"] == ["middle", "actual"] + assert snapshot.cache.openshell_selected_paths == {REGISTRATION, "policy-link", "middle", "actual"} + + +def test_policy_only_worktree_change_binds_regular_and_link_objects(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection("policy-link"), "actual": POLICY}, + {"README.md": "change"}, links={"policy-link": "actual"}) + (root / "actual").write_text(POLICY.replace("enforcement: enforce", "enforcement: audit")) + result = comparison(root) + assert result.comparison_status == "comparable" + bindings = {blob.path: blob.source for blob in result.input_script_blobs} + assert bindings == {REGISTRATION: "worktree", "actual": "worktree", "policy-link": "generated"} + + +@pytest.mark.parametrize("configured", [True, False]) +def test_ignored_selected_policy_invalidates_existing_control(repo, configured): + if not configured: + (repo / "shipgate.yaml").unlink() + _git(repo, "add", "-A") + _git(repo, "commit", "-m", "host-only workspace") + (repo / ".gitignore").write_text("agents-shipgate-reports/\nprivate/\n") + _git(repo, "add", ".gitignore") + _git(repo, "commit", "-m", "ignore selected policy") + target = register(repo, "private/policy") + if configured: + _verify(repo, archive_head=False) + plan = json.loads((repo / "agents-shipgate-reports/verification-plan.json").read_text()) + assert "private/policy" in {item["path"] for item in plan["inputs"]["options"]["dependency_inputs"]["files"]} + else: + result = CliRunner().invoke(app, ["verify", "--workspace", str(repo), "--base", "main", "--json"]) + assert result.exit_code in (0, 10, 20), result.output + out = repo / "agents-shipgate-reports" + read_current_control(out, live=lambda: _live(repo)) + target.write_text(POLICY.replace("enforcement: enforce", "enforcement: audit")) + with pytest.raises(CurrentControlUnavailable): + read_current_control(out, live=lambda: _live(repo)) + + +def test_ignored_selected_link_retargeting_invalidates_configured_receipt(repo): + (repo / ".gitignore").write_text("agents-shipgate-reports/\nprivate/\n") + _git(repo, "add", ".gitignore") + _git(repo, "commit", "-m", "ignore bundle") + target = register(repo, "private/link") + target.unlink() + one, two = repo / "private/one", repo / "private/two" + one.write_text(POLICY) + two.write_text(POLICY) + target.symlink_to("one") + _verify(repo, archive_head=False) + out = repo / "agents-shipgate-reports" + plan = json.loads((out / "verification-plan.json").read_text()) + links = plan["inputs"]["options"]["dependency_inputs"]["links"] + assert [item["path"] for item in links] == ["private/link"] + read_current_control(out, live=lambda: _live(repo)) + target.unlink() + target.symlink_to("two") + with pytest.raises(CurrentControlUnavailable): + read_current_control(out, live=lambda: _live(repo)) + + +def test_unrelated_hook_link_does_not_acquire_a_policy_dependency(tmp_path): + settings = tmp_path / ".claude/settings.json" + settings.parent.mkdir() + settings.write_text(json.dumps({"hooks": {"SessionStart": [{"hooks": [{"type": "command", "command": '"${CLAUDE_PROJECT_DIR}/guard.sh"'}]}]}})) + (tmp_path / "target").write_text("not policy") + (tmp_path / "guard.sh").symlink_to("target") + cache = HostStaticParseCache() + build_host_boundary_snapshot(tmp_path, cache=cache) + assert not cache.openshell_selected_paths and not cache.openshell_input_reads + + +def test_credential_shaped_link_target_is_unconfirmable_without_path_disclosure(tmp_path): + secret = "sk-live-0123456789abcdef0123456789abcdef" + register(tmp_path, secret) + (tmp_path / REGISTRATION).write_text(json.dumps(selection("policy-link"))) + (tmp_path / "policy-link").symlink_to(secret) + snapshot = build_host_boundary_snapshot(tmp_path) + published = json.dumps(snapshot.inventory) + json.dumps(snapshot.cache.openshell_input_reads) + assert secret not in published + assert not snapshot.inventory["grants"] + assert snapshot.cache.openshell_input_reads["policy-link"]["limit"] == "redacted_input_path" + + +def test_selected_hook_link_keeps_unconfirmable_hook_capture(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection("guard.sh"), "actual": POLICY, + ".claude/settings.json": {"hooks": {"SessionStart": [{"hooks": [{"type": "command", + "command": '"${CLAUDE_PROJECT_DIR}/guard.sh"'}]}]}}}, {"README.md": "change"}, + links={"guard.sh": "actual"}) + result = comparison(root) + assert "guard.sh" in result.input_script_unconfirmable_paths