Skip to content
Merged
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
26 changes: 24 additions & 2 deletions docs/openshell-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
15 changes: 11 additions & 4 deletions src/agents_shipgate/cli/verify/host_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 2 additions & 0 deletions src/agents_shipgate/cli/verify/host_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/agents_shipgate/core/current_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion src/agents_shipgate/core/host_grants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/agents_shipgate/core/openshell_inputs.py
Original file line number Diff line number Diff line change
@@ -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"}
21 changes: 21 additions & 0 deletions src/agents_shipgate/core/static_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down
26 changes: 21 additions & 5 deletions src/agents_shipgate/core/verification_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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)
Expand Down Expand Up @@ -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"]
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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")
Expand Down
Loading
Loading