diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 1b25d3d7..d7922628 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -64,7 +64,7 @@ (.*/)?\.claude/(settings(\.local)?\.json|commands(/.*)?|hooks/hooks\.json)| (.*/)?\.cursor/(cli\.json|mcp\.json|rules(/.*)?)| (.*/)?\.vscode/mcp\.json| - (.*/)?\.shipgate/agent-contract\.json| + (.*/)?\.shipgate/(agent-contract|openshell)\.json| (.*/)?(AGENTS(\.override)?|CLAUDE)\.md| \.(agents|claude)/skills/.*| (.*/)?\.codex-plugin(/.*)?| @@ -112,7 +112,7 @@ (.*/)?\.claude/(settings(\.local)?\.json|commands(/.*)?|hooks/hooks\.json)| (.*/)?\.cursor/(cli\.json|mcp\.json|rules(/.*)?)| (.*/)?\.vscode/mcp\.json| - (.*/)?\.shipgate/agent-contract\.json| + (.*/)?\.shipgate/(agent-contract|openshell)\.json| (.*/)?(AGENTS(\.override)?|CLAUDE)\.md| \.(agents|claude)/skills/.*| (.*/)?\.codex-plugin(/.*)?| diff --git a/docs/checks.json b/docs/checks.json index 6c052036..3baa0645 100644 --- a/docs/checks.json +++ b/docs/checks.json @@ -2090,17 +2090,22 @@ "autofix_safe": false, "category": "host_boundary", "default_severity": "high", - "description": "The Claude Code permission allowlist expanded.", + "description": "The Claude Code permission allowlist expanded. Also reports proven expansion of an explicitly selected OpenShell policy.", "docs_url": "https://github.com/ThreeMoonsLab/agents-shipgate/blob/main/docs/checks.md#ship-host-boundary-permission-allow-expanded", "dynamic_default": false, "evidence_fields": [ + "direction", + "explanation", "kind", - "rule", + "limits", "mode", + "narrowed", + "rule", "setting", - "value" + "value", + "widened" ], - "fires_when": "A changed .claude/settings.json or .claude/settings.local.json adds a non-wildcard permissions.allow entry. It also fires when the change sets any other value of a Claude Code setting the host-grant table models - a 'defaultMode' such as 'acceptEdits' or 'dontAsk', an 'enabledMcpjsonServers' entry, 'disableBypassPermissionsMode', 'disableAllHooks', the managed-only switches, or 'false' for the prompt switches - at the rating the table gives the value, which is the rating its grant and host-diff row carry (#827).", + "fires_when": "A changed .claude/settings.json or .claude/settings.local.json adds a non-wildcard permissions.allow entry. It also fires when the change sets any other value of a Claude Code setting the host-grant table models - a 'defaultMode' such as 'acceptEdits' or 'dontAsk', an 'enabledMcpjsonServers' entry, 'disableBypassPermissionsMode', 'disableAllHooks', the managed-only switches, or 'false' for the prompt switches - at the rating the table gives the value, which is the rating its grant and host-diff row carry (#827). OpenShell declared filesystem, Landlock or supported network authority expands, including removal of REST enforcement.", "floor_severity": "medium", "id": "SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED", "mvp_tier": "lifecycle", diff --git a/docs/checks.md b/docs/checks.md index 3eaed02f..8fdd5458 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -117,7 +117,7 @@ baseline summary and do not fail CI. | `SHIP-HOST-BOUNDARY-MCP-SERVER-ADDED` | high | A new MCP server was declared for the coding-agent host. | | `SHIP-HOST-BOUNDARY-MCP-SERVER-CHANGED` | high | An existing MCP server declaration changed its command, URL, args, or env keys. | | `SHIP-HOST-BOUNDARY-PERMISSION-WILDCARD-ALLOW` | critical | A Claude Code allow rule grants a wildcard surface over a tool that can execute, reach the network, or write, or a setting removes a prompt wholesale. | -| `SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED` | high | The Claude Code permission allowlist expanded, by a scoped rule or a read-only wildcard, or a modelled setting changed, at the setting's rating. | +| `SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED` | high | The Claude Code permission allowlist expanded, a modelled setting changed at its rating, or a selected OpenShell policy expanded declared authority. | | `SHIP-HOST-BOUNDARY-PERMISSION-DENY-REMOVED` | high | A Claude Code permission deny rule was removed. | | `SHIP-HOST-BOUNDARY-HOOK-CHANGED` | high | Claude Code hooks changed. | | `SHIP-HOST-BOUNDARY-WORKFLOW-WRITE-ALL` | critical | A GitHub workflow grants write-all permissions. | diff --git a/docs/checks/host_boundary.yaml b/docs/checks/host_boundary.yaml index 5167bf7e..273de5ae 100644 --- a/docs/checks/host_boundary.yaml +++ b/docs/checks/host_boundary.yaml @@ -116,18 +116,28 @@ checks: floor_severity: medium mvp_tier: lifecycle requires_human_review: true - description: The Claude Code permission allowlist expanded. - rationale: Every new allow rule widens what the host executes without a - prompt; expansion needs a human in the loop. - fires_when: A changed .claude/settings.json or .claude/settings.local.json - adds a non-wildcard permissions.allow entry. It also fires when the - change sets any other value of a Claude Code setting the host-grant table - models - a 'defaultMode' such as 'acceptEdits' or 'dontAsk', an - 'enabledMcpjsonServers' entry, 'disableBypassPermissionsMode', - 'disableAllHooks', the managed-only switches, or 'false' for the prompt - switches - at the rating the table gives the value, which is the rating - its grant and host-diff row carry (#827). - evidence_fields: [kind, rule, mode, setting, value] + description: The Claude Code permission allowlist expanded. Also reports proven expansion of an explicitly + selected OpenShell policy. + rationale: Every new allow rule widens what the host executes without a prompt; expansion needs a human + in the loop. + fires_when: A changed .claude/settings.json or .claude/settings.local.json adds a non-wildcard permissions.allow + entry. It also fires when the change sets any other value of a Claude Code setting the host-grant + table models - a 'defaultMode' such as 'acceptEdits' or 'dontAsk', an 'enabledMcpjsonServers' entry, + 'disableBypassPermissionsMode', 'disableAllHooks', the managed-only switches, or 'false' for the prompt + switches - at the rating the table gives the value, which is the rating its grant and host-diff row + carry (#827). OpenShell declared filesystem, Landlock or supported network authority expands, including + removal of REST enforcement. + evidence_fields: + - direction + - explanation + - kind + - limits + - mode + - narrowed + - rule + - setting + - value + - widened recommendation: Have a human approve the new permission allow rule. - id: SHIP-HOST-BOUNDARY-PERMISSION-DENY-REMOVED default_severity: high diff --git a/docs/integrations.md b/docs/integrations.md index 3450e752..dd21d8da 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -436,7 +436,7 @@ repos: (.*/)?\.claude/(settings(\.local)?\.json|commands(/.*)?|hooks/hooks\.json)| (.*/)?\.cursor/(cli\.json|mcp\.json|rules(/.*)?)| (.*/)?\.vscode/mcp\.json| - (.*/)?\.shipgate/agent-contract\.json| + (.*/)?\.shipgate/(agent-contract|openshell)\.json| (.*/)?(AGENTS(\.override)?|CLAUDE)\.md| \.(agents|claude)/skills/.*| (.*/)?\.codex-plugin(/.*)?| diff --git a/docs/openshell-support.md b/docs/openshell-support.md index 2200e396..30bd6f26 100644 --- a/docs/openshell-support.md +++ b/docs/openshell-support.md @@ -48,9 +48,7 @@ Defaults follow the pinned [OpenShell v0.1.2 authored schema](https://github.com and [conversion code](https://github.com/NVIDIA/OpenShell/blob/v0.1.2/crates/openshell-policy/src/lib.rs). The [upstream schema reference](https://docs.nvidia.com/openshell/how-it-works/policies/schema) describes runtime constraints beyond document inventory. This reader is not a -substitute for upstream policy validation. Git dependency identity, gate -integration, local composition and native proof are separate implementation -stages (#945–#948). +substitute for upstream policy validation. Local composition and native proof are separate implementation stages (#947–#948). ## Conservative declared-authority comparison @@ -146,3 +144,55 @@ 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. + +## Local control and verifier routing + +`check` evaluates selected OpenShell inputs for Codex, Claude Code and Cursor +callers alike. It reads both compared trees, including selection-only edits, +deleted registrations, arbitrary filenames and link targets. The same policy +comparator supplies `audit --host --drift`, `diff`, `check` and verifier rows. + +A proved expansion uses `SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED`. Mixed +changes keep proven expansions. Unknown authority uses the existing protected +surface review rule. Unread/malformed/unsupported inputs use the existing +incomplete-input route, so absent grant rows never authorize completion. +Neutral or proved narrowings can clear only their exact selected document's +obligation; other trust-root, manifest, policy and instruction edits still apply. +Selection edits and changed link identities retain separate review obligations. + +Trigger evaluation takes exact selected paths as explicit context, so even a +selected `README.md` overrides a docs-only skip. Preflight protects selected +inputs as exact host trust roots, including ignored files; the trust graph +binds their captured identity without treating filenames as globs. + +With a configured application gate, verifier findings project to the normal +release decision, control permissions, Markdown and SARIF. Without one, +`verify` remains an advisory host comparison and routes to `audit --host`; +it requires no invented purpose, action effects, authority or agent bindings, +and establishes no application merge verdict. Preview never grants completion. + +For an end-to-end exercise, register the sample policy, commit it as the base, +then remove `enforcement: enforce` from its REST endpoint. Run: + +```bash +shipgate audit --host --workspace . --json +shipgate diff --workspace . --base main --json +shipgate check --agent codex --workspace . --base main --format agent-boundary-json +shipgate verify --workspace . --base main --json +shipgate agent control --workspace . --reports-dir agents-shipgate-reports +``` + +The change widens well-formed REST request authority by restoring audit mode. +Review the existing control route and its permissions. A subsequent selected +policy edit invalidates the receipt/current control, including ignored paths. +The route-parity fixtures exercise this transition for all three callers, +configured verification and SARIF, plus malformed input and Git-tree isolation. +Validation is pinned to OpenShell v0.1.2/schema 1 and the supported comparison +subset above. Gateway state, live enforcement, credentials, provider/global +composition and native containment remain outside this static MVP. + +The pre-commit hook recognizes OpenShell selection files. Its static filename +filter cannot identify an arbitrary selected policy path on its own. Run +`shipgate check` or `agents-shipgate verify` for those changes, or set +`always_run: true` on the local hook. The GitHub verifier reads the explicit +selection and evaluates its dependencies. diff --git a/docs/triggers.json b/docs/triggers.json index 58f72ad3..f201420e 100644 --- a/docs/triggers.json +++ b/docs/triggers.json @@ -65,6 +65,17 @@ } ], "rules": [ + { + "id": "TRIGGER-OPENSHELL-BOUNDARY-CHANGED", + "surface_class": "host_boundary", + "agents_md_row": "Adds/changes coding-agent host config, hooks, permissions, MCP servers, or workflows", + "when": { + "boundary_adapter": "openshell" + }, + "action": "run_shipgate", + "rationale": "Explicit OpenShell policy selections define a reviewed sandbox authority surface.", + "command": "agents-shipgate verify --preview --json" + }, { "id": "TRIGGER-MCP-EXPORT-CHANGED", "surface_class": "capability", diff --git a/examples/pre-commit/README.md b/examples/pre-commit/README.md index 45db8269..210cdf2c 100644 --- a/examples/pre-commit/README.md +++ b/examples/pre-commit/README.md @@ -60,7 +60,7 @@ repos: (.*/)?\.claude/(settings(\.local)?\.json|commands(/.*)?|hooks/hooks\.json)| (.*/)?\.cursor/(cli\.json|mcp\.json|rules(/.*)?)| (.*/)?\.vscode/mcp\.json| - (.*/)?\.shipgate/agent-contract\.json| + (.*/)?\.shipgate/(agent-contract|openshell)\.json| (.*/)?(AGENTS(\.override)?|CLAUDE)\.md| \.(agents|claude)/skills/.*| (.*/)?\.codex-plugin(/.*)?| diff --git a/llms-full.txt b/llms-full.txt index e4cb34bf..174bcd1f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -3523,7 +3523,7 @@ baseline summary and do not fail CI. | `SHIP-HOST-BOUNDARY-MCP-SERVER-ADDED` | high | A new MCP server was declared for the coding-agent host. | | `SHIP-HOST-BOUNDARY-MCP-SERVER-CHANGED` | high | An existing MCP server declaration changed its command, URL, args, or env keys. | | `SHIP-HOST-BOUNDARY-PERMISSION-WILDCARD-ALLOW` | critical | A Claude Code allow rule grants a wildcard surface over a tool that can execute, reach the network, or write, or a setting removes a prompt wholesale. | -| `SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED` | high | The Claude Code permission allowlist expanded, by a scoped rule or a read-only wildcard, or a modelled setting changed, at the setting's rating. | +| `SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED` | high | The Claude Code permission allowlist expanded, a modelled setting changed at its rating, or a selected OpenShell policy expanded declared authority. | | `SHIP-HOST-BOUNDARY-PERMISSION-DENY-REMOVED` | high | A Claude Code permission deny rule was removed. | | `SHIP-HOST-BOUNDARY-HOOK-CHANGED` | high | Claude Code hooks changed. | | `SHIP-HOST-BOUNDARY-WORKFLOW-WRITE-ALL` | critical | A GitHub workflow grants write-all permissions. | diff --git a/src/agents_shipgate/checks/verify.py b/src/agents_shipgate/checks/verify.py index d9b1d9d2..d4361ba1 100644 --- a/src/agents_shipgate/checks/verify.py +++ b/src/agents_shipgate/checks/verify.py @@ -50,6 +50,9 @@ def run(context: ScanContext) -> list[Finding]: verification = context.verification if verification is None: return [] + from agents_shipgate.core.agent_boundary import assessment_for_scan_context + + assessment = assessment_for_scan_context(context) findings: list[Finding] = [] seen: set[str] = set() for raw in verification.changed_files: @@ -58,6 +61,10 @@ def run(context: ScanContext) -> list[Finding]: continue seen.add(path) classification = _configured_manifest(context, path) or _classify(path) + if path in assessment.openshell_safe_paths and classification is None: + continue + if classification is None and path in assessment.openshell_paths: + classification = ("host_boundary", path) if classification is None: continue trust_root_class, matched_glob = classification diff --git a/src/agents_shipgate/cli/agent_result.py b/src/agents_shipgate/cli/agent_result.py index 9cb827f3..20b370e5 100644 --- a/src/agents_shipgate/cli/agent_result.py +++ b/src/agents_shipgate/cli/agent_result.py @@ -322,6 +322,10 @@ def _assessment_for_diff( ) if evidence_issues: input_issues = [*(input_issues or []), *evidence_issues] + if enabled_plugin_hooks and enabled_plugin_hooks.openshell: + trigger = evaluate_trigger(paths=changed_files, diff_text=diff_text, + manifest_present=manifest_present, user_requested=True, + selected_host_paths=sorted(enabled_plugin_hooks.openshell.paths)) return evaluate_agent_boundary( workspace=workspace, diff_text=diff_text, diff --git a/src/agents_shipgate/cli/trigger.py b/src/agents_shipgate/cli/trigger.py index 65b88bc8..8afa4b7a 100644 --- a/src/agents_shipgate/cli/trigger.py +++ b/src/agents_shipgate/cli/trigger.py @@ -193,6 +193,17 @@ def trigger( ) raise typer.Exit(2) from exc + from agents_shipgate.cli.verify.host_comparison import enabled_plugin_hook_evidence + from agents_shipgate.core.host_grants import build_host_boundary_snapshot + + if use_git: + _, files, issues = enabled_plugin_hook_evidence(workspace=workspace.resolve(), + changed_files=paths, head_is_worktree=base is None, + base=base or head or "HEAD", head=(head or "HEAD") if base else None) + selected = sorted(files.openshell.paths) if files and files.openshell else [] + else: + selected = sorted(build_host_boundary_snapshot(workspace).cache.openshell_selected_paths) + issues = [] result = evaluate( paths=paths, diff_text=diff_text, @@ -200,6 +211,8 @@ def trigger( detect_result=detect_result, user_requested=user_requested, triggers=triggers, + selected_host_paths=selected, + input_status="partial" if issues else "complete", ) if json_output: diff --git a/src/agents_shipgate/cli/verify/host_comparison.py b/src/agents_shipgate/cli/verify/host_comparison.py index 8738d3eb..fd2211d4 100644 --- a/src/agents_shipgate/cli/verify/host_comparison.py +++ b/src/agents_shipgate/cli/verify/host_comparison.py @@ -339,6 +339,12 @@ def enabled_plugin_hook_evidence( if not candidates or not base: return None, None, [] declarations_changed = any(is_boundary_surface_path(path) for path in candidates) + from dataclasses import replace + + from agents_shipgate.cli.verify.host_tree import materialize_host_tree + from agents_shipgate.core.openshell_boundary import OpenShellBoundaryEvidence + + openshell_sides = [] snapshot: HostBoundarySnapshot | None = None files = EnabledPluginHookFiles() @@ -352,6 +358,10 @@ def enabled_plugin_hook_evidence( ] if declarations_changed else [] snapshot = build_host_boundary_snapshot(workspace, scope="repository") files = files.union(EnabledPluginHookFiles.of(snapshot)) + openshell_sides.append(snapshot) + if snapshot.cache.openshell_selected_paths: + if not commits: + commits.append(commit_sha(workspace, "HEAD") if base == "HEAD" else merge_base_sha(workspace, base, "HEAD")) else: head_ref = head or "HEAD" # Preserve each host's selection from both declaration trees. @@ -363,23 +373,37 @@ def enabled_plugin_hook_evidence( raise ValueError("a compared commit is not available locally") with tempfile.TemporaryDirectory(prefix="shipgate-plugin-hooks-") as scratch: tree = Path(scratch) / "tree" - archive_tree(workspace, commit, tree, scope=is_boundary_surface_path) - files = files.union(EnabledPluginHookFiles.of(build_host_boundary_snapshot( - tree, cache=HostStaticParseCache(reference_workspace=workspace), - ))) + tree, archived = materialize_host_tree(workspace, commit, tree, archive=archive_tree) + archived = archived or build_host_boundary_snapshot( + tree, cache=HostStaticParseCache(reference_workspace=workspace)) + openshell_sides.append(archived) + if not head_is_worktree and len(commits) == 1 and archived.cache.openshell_selected_paths: + commits.append(merge_base_sha(workspace, base, head or "HEAD")) + files = files.union(EnabledPluginHookFiles.of(archived)) except (OSError, RuntimeError, ValueError, ConfigError): return snapshot, None, [ BoundaryInputIssue( code="host_inventory_unreadable", path=path, message=( - "The hook configuration of a compared commit could not be read, so " + "The selected host inputs of a compared commit could not be read, so " "whether this file declares selected plugin hooks or is a selected " "hook executable is not established." ), ) for path in candidates ] + if openshell_sides: + # Head is read first; the immutable merge base is the final side. + head_side, base_side = openshell_sides[0], openshell_sides[-1] + paths = frozenset(base_side.cache.openshell_selected_paths | head_side.cache.openshell_selected_paths) + before_reads, after_reads = base_side.cache.openshell_input_reads, head_side.cache.openshell_input_reads + links = frozenset(path for path in before_reads.keys() | after_reads.keys() + if any(reads.get(path, {}).get("source") == "generated" for reads in (before_reads, after_reads)) + and before_reads.get(path) != after_reads.get(path)) + files = replace(files, openshell=OpenShellBoundaryEvidence(paths=paths, changed_links=links, + before=base_side.inventory if len(openshell_sides) > 1 else None, + after=head_side.inventory)) return snapshot, files, [] diff --git a/src/agents_shipgate/cli/verify/orchestrator.py b/src/agents_shipgate/cli/verify/orchestrator.py index 40a9adae..f8417d75 100644 --- a/src/agents_shipgate/cli/verify/orchestrator.py +++ b/src/agents_shipgate/cli/verify/orchestrator.py @@ -958,6 +958,12 @@ def run_verify( ) ) + if enabled_plugin_hooks and enabled_plugin_hooks.openshell: + trigger = evaluate(paths=changed_files, diff_text=diff_text, + manifest_present=config_path.exists(), user_requested=True, + input_status=_trigger_input_status(diff_input), + selected_host_paths=sorted(enabled_plugin_hooks.openshell.paths)) + report: ReadinessReport | None = None head_status = "failed" head_exit_code = 4 diff --git a/src/agents_shipgate/core/agent_boundary.py b/src/agents_shipgate/core/agent_boundary.py index 2618e41b..72af700e 100644 --- a/src/agents_shipgate/core/agent_boundary.py +++ b/src/agents_shipgate/core/agent_boundary.py @@ -117,6 +117,8 @@ class AgentBoundaryAssessment: legacy_result: AgentResultV2 instruction_structure_unchanged: frozenset[str] = frozenset() host_settings_narrowed: frozenset[str] = frozenset() + openshell_paths: frozenset[str] = frozenset() + openshell_safe_paths: frozenset[str] = frozenset() @dataclass(frozen=True) @@ -206,6 +208,16 @@ def evaluate_agent_boundary( for path in changed_files if is_enabled_plugin_hook_source(path, plugin_hooks.sources) ) + from agents_shipgate.core.openshell_boundary import ( + OpenShellBoundaryEvidence, + assess_openshell_boundary, + ) + + openshell = plugin_hooks.openshell or OpenShellBoundaryEvidence( + paths=frozenset(host_snapshot.cache.openshell_selected_paths), + after=host_snapshot.inventory, + ) + openshell_paths = openshell.paths & frozenset(changed_files) script_hosts: dict[str, list[str]] = {} changed_set = set(changed_files) for host, path in sorted(plugin_hooks.scripts): @@ -226,6 +238,9 @@ def evaluate_agent_boundary( script_issues = hook_dependency_issues(host_snapshot.inventory) def counted(item: dict[str, Any]) -> bool: + if (item.get("host") == "openshell" and plugin_hooks.openshell is not None + and str(item.get("source") or "") in plugin_hooks.openshell.paths): + return False # The explicitly compared sides supply these issues below. source = str(item.get("source") or "").replace("\\", "/") if str(item.get("issue_id")) in script_issues: # A selected hook script this check could not read (#702) is its @@ -326,6 +341,16 @@ def counted(item: dict[str, Any]) -> bool: resolved_text_cache=resolved_text_cache, static_read_cache=host_snapshot.cache, ) + openshell_violations, openshell_diagnostics, openshell_issues, openshell_safe = ( + assess_openshell_boundary(openshell, changed_set, policies.host) + ) + host_violations.extend(openshell_violations) + host_diagnostics.extend(openshell_diagnostics) + input_issues.extend(openshell_issues) + openshell_safe = {path for path in openshell_safe + if trust_root_class_for(path) is None and not is_agent_boundary_path(path) + and not is_configured_manifest(config_path, path, workspace=workspace) + and not _is_invocation_path(policy_path, path, workspace=workspace)} # A host settings change the permission lattice decides only narrows was # evaluated completely (#661): like an unchanged instruction structure, it # is not an unclassified protected change and needs no human. @@ -372,6 +397,7 @@ def counted(item: dict[str, Any]) -> bool: evaluated_paths={ *instruction_structure_unchanged, *host_settings_narrowed, + *(openshell_safe if not input_issues else set()), *( item.path for item in diagnostics @@ -534,6 +560,7 @@ def counted(item: dict[str, Any]) -> bool: ), *({"claude-code"} if plugin_hook_paths or plugin_unread_paths else set()), *(host for hosts in script_hosts.values() for host in hosts), + *({"openshell"} if openshell_paths else set()), } ) ) @@ -544,6 +571,7 @@ def counted(item: dict[str, Any]) -> bool: invocation_shared_paths=invocation_shared_paths, plugin_hook_paths=plugin_hook_paths | plugin_unread_paths, script_hosts=script_hosts, + openshell_paths=openshell_paths, ) input_coverage: Literal["complete", "partial", "unknown"] = ( "partial" @@ -574,6 +602,8 @@ def counted(item: dict[str, Any]) -> bool: legacy_result=projected, instruction_structure_unchanged=frozenset(instruction_structure_unchanged), host_settings_narrowed=frozenset(host_settings_narrowed), + openshell_paths=openshell.paths, + openshell_safe_paths=frozenset(openshell_safe if not input_issues else set()), ) @@ -1077,6 +1107,7 @@ def _coverage_for( invocation_shared_paths: set[str] | None = None, plugin_hook_paths: frozenset[str] = frozenset(), script_hosts: dict[str, list[str]] | None = None, + openshell_paths: frozenset[str] = frozenset(), ) -> list[BoundaryHostCoverage]: # A path is partially covered only when its content was not read. A kind # the publication predicate counts as read is never unread here, so a @@ -1096,6 +1127,8 @@ def _coverage_for( coverage: list[BoundaryHostCoverage] = [] for adapter in BOUNDARY_ADAPTERS: paths = sorted(path for path in changed_files if adapter.matches(path)) + if adapter.id == "openshell": + paths = sorted({*paths, *openshell_paths}) if adapter.id == "shared" and invocation_shared_paths: paths = sorted({*paths, *invocation_shared_paths}) if adapter.id == "claude_code" and plugin_hook_paths: diff --git a/src/agents_shipgate/core/host_grants.py b/src/agents_shipgate/core/host_grants.py index 9eaf1c19..bb36800a 100644 --- a/src/agents_shipgate/core/host_grants.py +++ b/src/agents_shipgate/core/host_grants.py @@ -417,6 +417,7 @@ class EnabledPluginHookFiles: unread: frozenset[str] = frozenset() #: Selected literal executable references, identified separately per host. scripts: frozenset[tuple[str, str]] = frozenset() + openshell: Any = None @classmethod def of(cls, snapshot: HostBoundarySnapshot) -> EnabledPluginHookFiles: @@ -437,6 +438,7 @@ def union(self, other: EnabledPluginHookFiles) -> EnabledPluginHookFiles: return EnabledPluginHookFiles( sources=self.sources | other.sources, unread=self.unread | other.unread, scripts=self.scripts | other.scripts, + openshell=self.openshell or other.openshell, ) diff --git a/src/agents_shipgate/core/openshell_boundary.py b/src/agents_shipgate/core/openshell_boundary.py new file mode 100644 index 00000000..0416bc3c --- /dev/null +++ b/src/agents_shipgate/core/openshell_boundary.py @@ -0,0 +1,66 @@ +"""Project the shared policy comparator through existing host review rules.""" +from __future__ import annotations + +from dataclasses import dataclass + +from agents_shipgate.core.boundary_diff import BoundaryInputIssue +from agents_shipgate.core.boundary_rules import GENERIC_BOUNDARY_RULES +from agents_shipgate.core.host_boundary import DEFAULT_RULES, HostBoundaryPolicy +from agents_shipgate.core.openshell_compare import compare_openshell_grants +from agents_shipgate.schemas.agent_result_v1 import AgentResultDiagnostic, AgentResultViolatedRule + + +@dataclass(frozen=True) +class OpenShellBoundaryEvidence: + paths: frozenset[str] = frozenset() + before: dict | None = None + after: dict | None = None + changed_links: frozenset[str] = frozenset() + + +def assess_openshell_boundary(evidence: OpenShellBoundaryEvidence, changed: set[str], + policy: HostBoundaryPolicy): + touched = evidence.paths & changed + if not touched: + return [], [], [], set() + violations, diagnostics, issues = [], [], [] + for inventory in (evidence.before, evidence.after): + for issue in (inventory or {}).get("issues", []): + if issue.get("host") == "openshell" and issue.get("blocking"): + issues.append(BoundaryInputIssue(code="openshell_input_unresolved", + path=issue.get("source"), message=issue["message"])) + if touched & evidence.changed_links: + changes = [(None, None, path) for path in sorted(touched & evidence.changed_links)] + elif evidence.before is None or evidence.after is None: + changes = [(None, None, path) for path in sorted(touched)] + else: + def grants(inventory): + return {row["grant_id"]: row for row in inventory.get("grants", []) + if row.get("kind") == "openshell_policy"} + before, after = grants(evidence.before), grants(evidence.after) + changes = [(before.get(key), after.get(key), + (after.get(key) or before[key])["source"]) + for key in sorted(before.keys() | after.keys())] + safe = set(touched) if not issues else set() + for before, after, path in changes: + result = compare_openshell_grants(before, after) + if result.direction in {"equivalent", "narrowed"}: + diagnostics.append(AgentResultDiagnostic(level="info", code="openshell_policy_narrowed", + path=path, message=result.explanation)) + continue + safe.clear() + rule = (policy.rules.get("HOST-PERMISSION-ALLOW-EXPANDED") + or DEFAULT_RULES["HOST-PERMISSION-ALLOW-EXPANDED"]) if result.widened else ( + GENERIC_BOUNDARY_RULES["PROTECTED-SURFACE-UNCLASSIFIED"]) + violations.append(AgentResultViolatedRule(id=rule.id, check_id=rule.check_id, + action=rule.action, risk_level=rule.risk_level, + title="OpenShell declared policy requires review", path=path, + evidence={"kind": "openshell_policy_changed", "direction": result.direction, + "widened": list(result.widened), "narrowed": list(result.narrowed), + "limits": list(result.limits), "explanation": result.explanation}, + recommendation=rule.recommendation)) + # Selection files define which document is reviewed. Changing that trust + # root is a separate obligation even if every individual grant is neutral. + safe = {path for path in safe if not path.endswith("/.shipgate/openshell.json") + and path != ".shipgate/openshell.json"} + return violations, diagnostics, issues, safe diff --git a/src/agents_shipgate/core/preflight.py b/src/agents_shipgate/core/preflight.py index 5758f3cd..05672459 100644 --- a/src/agents_shipgate/core/preflight.py +++ b/src/agents_shipgate/core/preflight.py @@ -328,7 +328,8 @@ def build_preflight_result( ] verify_command = _verify_command(workspace, config) identity_paths = {touch.path for touch in path_identity_touches} - classified_touches = classify_protected_touches(changed, config_path, root) + classified_touches = classify_protected_touches(changed, config_path, root, + selected_paths={node.pattern for node in graph.nodes if node.kind == "host_boundary"}) touches = [ *path_identity_touches, *( @@ -647,6 +648,22 @@ def file_hashes_for(paths: list[str]) -> dict[str, str]: }, ) ) + from agents_shipgate.core.host_grants import build_host_boundary_snapshot, public_host_path + + registrations = [path for path in candidate_paths + if path.casefold() == ".shipgate/openshell.json" + or path.casefold().endswith("/.shipgate/openshell.json")] + selected_snapshot = build_host_boundary_snapshot(root) if registrations else None + for path in sorted(selected_snapshot.cache.openshell_selected_paths if selected_snapshot else []): + if _classify(path) is not None or public_host_path(path) != path: + continue + captured = selected_snapshot.cache.openshell_input_reads.get(path, {}) + digest = captured.get("sha256") + present = [path] if digest else [] + nodes.append(TrustRootNodeV2(id=_node_id("host_boundary", path), + kind="host_boundary", pattern=path, scope_type="whole_file", + present_paths=present, file_hashes={path: "sha256:" + digest} if digest else {})) + nodes.sort(key=lambda item: (item.kind, item.pattern)) if configured_relative and not configured_is_catalogued: # The configured manifest is an exact identity, not a glob. Legal Git # filenames may themselves contain ``*``, ``?``, ``[]``, or ``\``. @@ -682,10 +699,20 @@ def file_hashes_for(paths: list[str]) -> dict[str, str]: ) +def _selected_openshell_paths(root: Path) -> set[str]: + from agents_shipgate.core.host_grants import build_host_boundary_snapshot, public_host_path + + snapshot = build_host_boundary_snapshot(root) + # Redacted locations are labels, never graph locators. + return {path for path in snapshot.cache.openshell_selected_paths + if public_host_path(path) == path} + + def classify_protected_touches( changed_files: list[str], config_path: Path | None = None, workspace: Path | None = None, + *, selected_paths: set[str] | None = None, ) -> list[PreflightProtectedSurfaceTouchV2]: """Classify changed paths against the protected-surface catalog. @@ -696,6 +723,8 @@ def classify_protected_touches( """ touches: list[PreflightProtectedSurfaceTouchV2] = [] + selected = (selected_paths if selected_paths is not None else + _selected_openshell_paths(workspace) if workspace is not None else set()) seen: set[str] = set() for raw in changed_files: path = raw.replace("\\", "/") @@ -703,6 +732,8 @@ def classify_protected_touches( continue seen.add(path) spec = _configured_manifest_spec(config_path, path, workspace) or _classify(path) + if spec is None and path in selected: + spec = ProtectedSurfaceSpec(kind="host_boundary", pattern=path, scope_type="whole_file") if spec is None: continue touches.append( diff --git a/src/agents_shipgate/triggers.py b/src/agents_shipgate/triggers.py index 29ac39cb..aa918be2 100644 --- a/src/agents_shipgate/triggers.py +++ b/src/agents_shipgate/triggers.py @@ -469,6 +469,7 @@ def evaluate( user_requested: bool = False, triggers: dict[str, Any] | None = None, input_status: str = INPUT_COMPLETE, + selected_host_paths: Sequence[str] = (), ) -> dict[str, Any]: """Evaluate the trigger catalog against a snapshot of repo state. @@ -587,6 +588,13 @@ def evaluate( } ) + selected_changed = set(paths) & set(selected_host_paths) + if selected_changed: + matched.append({"id": "TRIGGER-OPENSHELL-SELECTED-INPUT", + "action": ACTION_FORCE_RUN, "surface_class": SURFACE_CLASS_HOST_BOUNDARY, + "rationale": "An explicitly selected OpenShell input changed.", + "command": retarget_command("agents-shipgate verify --preview --json")}) + stop_block = triggers.get("stop_conditions") or {} stop_payload = {k: v for k, v in stop_block.items() if k != "description"} # The stop block can only be trusted when it is fully evaluable. If it diff --git a/tests/test_openshell_routes.py b/tests/test_openshell_routes.py new file mode 100644 index 00000000..313f18a6 --- /dev/null +++ b/tests/test_openshell_routes.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json + +import pytest +from test_openshell_inputs import POLICY, REGISTRATION, selection +from test_partial_host_comparison import _git, _repository +from typer.testing import CliRunner + +from agents_shipgate.cli.main import app + + +def invoke(root, *args): + result = CliRunner().invoke(app, [*args, "--workspace", str(root)]) + assert result.exit_code in (0, 10, 20), result.output + return json.loads(result.output) + + +@pytest.mark.parametrize("actor", ["codex", "claude-code", "cursor"]) +def test_audit_transition_routes_same_selected_policy_for_every_caller(tmp_path, actor): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY}, + {"arbitrary.rules": POLICY.replace("enforcement: enforce", "enforcement: audit")}) + checked = invoke(root, "check", "--agent", actor, "--base", "main", "--head", "HEAD", + "--format", "agent-boundary-json") + assert "openshell" in checked["affected_hosts"] + assert checked["input_coverage"] == "complete" + violation, = [row for row in checked["violations"] if row["evidence"]["kind"] == "openshell_policy_changed"] + assert violation["evidence"]["direction"] == "widened" + assert checked["control"]["state"] != "complete" + diff = invoke(root, "diff", "--base", "main", "--json") + preview = invoke(root, "verify", "--preview", "--base", "main", "--head", "HEAD", "--json") + verified = invoke(root, "verify", "--base", "main", "--head", "HEAD", "--json") + assert diff["rows"] == preview["host_comparison"]["rows"] == verified["host_comparison"]["rows"] + assert any(row["expands"] for row in diff["rows"]) + handoff = json.loads((root / "agents-shipgate-reports/agent-handoff.json").read_text()) + assert handoff["control"]["state"] != "complete" + + +def test_proven_narrowing_names_direction_and_never_clears_other_trust_roots(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY.replace("enforcement: enforce", "enforcement: audit")}, + {"arbitrary.rules": POLICY, "AGENTS.md": "new instructions"}) + checked = invoke(root, "check", "--base", "main", "--head", "HEAD", "--format", "agent-boundary-json") + assert any(row["direction"] == "narrowed" for row in checked["rows"]) + assert checked["control"]["state"] != "complete" + + +@pytest.mark.parametrize("value", ["version: [", POLICY.replace("enforcement: enforce", "enforcement: mystery")]) +def test_changed_unread_policy_cannot_complete_without_grants(tmp_path, value): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY}, {"arbitrary.rules": value}) + checked = invoke(root, "check", "--base", "main", "--head", "HEAD", "--format", "agent-boundary-json") + assert checked["input_coverage"] == "partial" + assert checked["control"]["permissions"]["report_complete"] is False + + +def test_committed_check_ignores_dirty_policy_content(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY}, + {"arbitrary.rules": POLICY.replace("enforcement: enforce", "enforcement: audit")}) + (root / "arbitrary.rules").write_text("version: [") + checked = invoke(root, "check", "--base", "main", "--head", "HEAD", "--format", "agent-boundary-json") + assert checked["input_coverage"] == "complete" + assert any(row["evidence"].get("direction") == "widened" for row in checked["violations"]) + + +def test_selection_removal_keeps_the_base_selected_input_visible(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY}, {"README.md": "change"}) + (root / REGISTRATION).unlink() + _git(root, "add", "-A") + _git(root, "commit", "-m", "remove selection") + checked = invoke(root, "check", "--base", "main", "--head", "HEAD", "--format", "agent-boundary-json") + assert "openshell" in checked["affected_hosts"] + assert checked["control"]["state"] != "complete" + + +def test_selected_docs_filename_overrides_docs_only_trigger(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection("README.md"), "README.md": POLICY}, + {"README.md": POLICY.replace("enforcement: enforce", "enforcement: audit")}) + triggered = invoke(root, "trigger", "--base", "main", "--head", "HEAD", "--json") + assert triggered["run_shipgate"] is True + assert any(row["id"] == "TRIGGER-OPENSHELL-SELECTED-INPUT" for row in triggered["matched_rules"]) + + +def test_preflight_protects_exact_selected_filename_and_binds_its_graph(tmp_path): + from agents_shipgate.core.preflight import build_trust_root_graph, classify_protected_touches + + root = _repository(tmp_path, {REGISTRATION: selection("policy[1]"), "policy[1]": POLICY, + "policy1": POLICY}, {"README.md": "change"}) + touches = classify_protected_touches(["policy[1]", "policy1"], workspace=root) + assert [touch.path for touch in touches] == ["policy[1]"] + first = build_trust_root_graph(root) + node, = [node for node in first.nodes if node.pattern == "policy[1]"] + assert node.present_paths == ["policy[1]"] + (root / "policy[1]").write_text(POLICY.replace("enforcement: enforce", "enforcement: audit")) + assert first.graph_hash != build_trust_root_graph(root).graph_hash + + +def test_link_retarget_with_same_policy_bytes_still_requires_reference_review(tmp_path): + root = _repository(tmp_path, {REGISTRATION: selection("link"), "one": POLICY, "two": POLICY}, + {"README.md": "change"}, links={"link": "one"}) + (root / "link").unlink() + (root / "link").symlink_to("two") + checked = invoke(root, "check", "--base", "main", "--format", "agent-boundary-json") + assert checked["control"]["state"] != "complete" + assert any(row["path"] == "link" for row in checked["violations"]) + + +def test_configured_verifier_publishes_review_and_sarif_for_audit_transition(tmp_path): + from pathlib import Path + + sample = Path(__file__).resolve().parent.parent / "samples/clean_read_only_agent" + root = _repository(tmp_path, {REGISTRATION: selection(), "arbitrary.rules": POLICY, + "shipgate.yaml": (sample / "shipgate.yaml").read_text(), + "tools.json": (sample / "tools.json").read_text()}, + {"arbitrary.rules": POLICY.replace("enforcement: enforce", "enforcement: audit")}) + verified = invoke(root, "verify", "--base", "main", "--head", "HEAD", "--json") + assert verified["release_decision"]["decision"] == "review_required" + assert verified["control"]["permissions"]["merge"] is False + report = json.loads((root / "agents-shipgate-reports/report.json").read_text()) + assert any(row["evidence"].get("direction") == "widened" for row in report["findings"]) + sarif = json.loads((root / "agents-shipgate-reports/report.sarif").read_text()) + assert any(row["ruleId"] == "SHIP-HOST-BOUNDARY-PERMISSION-ALLOW-EXPANDED" + for row in sarif["runs"][0]["results"])