diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index b8bf6af..03b06af 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -18,6 +18,7 @@ | workplan | GLAS-WP-0007 | finished | — | workplans/GLAS-WP-0007-capability-scope-truth.md | | workplan | GLAS-WP-0008 | finished | — | workplans/GLAS-WP-0008-profile-operational-readiness.md | | workplan | GLAS-WP-0009 | finished | — | workplans/GLAS-WP-0009-operational-handoff-truth.md | +| workplan | GLAS-WP-0010 | finished | — | workplans/GLAS-WP-0010-transport-and-resolution-hardening.md | | task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | | task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md | @@ -56,6 +57,9 @@ | task | GLAS-WP-0008-T03 | done | — | workplans/GLAS-WP-0008-profile-operational-readiness.md | | task | GLAS-WP-0009-T01 | done | — | workplans/GLAS-WP-0009-operational-handoff-truth.md | | task | GLAS-WP-0009-T02 | done | — | workplans/GLAS-WP-0009-operational-handoff-truth.md | +| task | GLAS-WP-0010-T01 | done | — | workplans/GLAS-WP-0010-transport-and-resolution-hardening.md | +| task | GLAS-WP-0010-T02 | done | — | workplans/GLAS-WP-0010-transport-and-resolution-hardening.md | +| task | GLAS-WP-0010-T03 | done | — | workplans/GLAS-WP-0010-transport-and-resolution-hardening.md | | intake | GLAS-IN-0001 | done | blue | docs/intakes/residuals.md | | intake | GLAS-IN-0002 | todo | red | docs/intakes/residuals.md | | intake | GLAS-IN-0003 | todo | green | docs/intakes/residuals.md | diff --git a/docs/execution-profiles.md b/docs/execution-profiles.md index 68eefc2..cdbd8f4 100644 --- a/docs/execution-profiles.md +++ b/docs/execution-profiles.md @@ -34,7 +34,9 @@ Each YAML document under `profiles/` declares: The catalog discovers files deterministically, forbids unknown schema fields, rejects duplicate revisions, and checks the selected profile against `registry/reins/*.yaml`. Unpinned lookup is accepted only when exactly one -enabled revision exists. Inline secrets and token-looking values are refused. +revision exists; disabled revisions still make a multi-revision selector +ambiguous so callers must pin deliberately. A pinned disabled revision is +refused. Inline secrets and token-looking values are refused. Use `glas-harness profiles` as the catalog/packaging validation command. diff --git a/docs/harness-contract.md b/docs/harness-contract.md index 736d6ba..d68af8e 100644 --- a/docs/harness-contract.md +++ b/docs/harness-contract.md @@ -79,6 +79,11 @@ subprocess with the profile timeout. A host must make the selected rein command and its dependencies available inside that transport; host-only installation is not treated as sandbox availability. +SSH reachability accepts only one non-option host or `user@host` target whose +components begin with an alphanumeric character. Glas also terminates SSH +option parsing with `--`; a reachability descriptor cannot reinterpret its +target as an SSH flag. + There is no production default rein. Direct `Rein` injection remains a narrow library/test seam but still requires a valid profile so profile, sandbox, model, tool policy, and evidence are explicit. diff --git a/src/glas_harness/transport.py b/src/glas_harness/transport.py index dc8248d..bba591b 100644 --- a/src/glas_harness/transport.py +++ b/src/glas_harness/transport.py @@ -25,7 +25,9 @@ class TransportError(RuntimeError): """The sandbox reachability descriptor cannot safely execute commands.""" -_SSH_TARGET = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$") +_SSH_TARGET = re.compile( + r"^(?:[A-Za-z0-9][A-Za-z0-9._-]*@)?[A-Za-z0-9][A-Za-z0-9._-]*$" +) @dataclass(frozen=True) @@ -63,9 +65,11 @@ class ExecutionTransport: *scoped_command, ] if self.kind == "ssh": - if not self.ssh_target: - raise TransportError("SSH transport is missing target") - return ["ssh", self.ssh_target, shlex.join(scoped_command)] + if not self.ssh_target or not _SSH_TARGET.fullmatch(self.ssh_target): + raise TransportError( + "SSH target must be a single non-option host or user@host target" + ) + return ["ssh", "--", self.ssh_target, shlex.join(scoped_command)] raise TransportError(f"unsupported execution transport: {self.kind}") def run( @@ -175,7 +179,9 @@ def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport: "incomplete remote sandbox reachability: ssh and remote_dir are required" ) if not _SSH_TARGET.fullmatch(str(ssh_target)): - raise TransportError("SSH reachability must be a single host or user@host target") + raise TransportError( + "SSH reachability must be a single non-option host or user@host target" + ) if not PurePosixPath(str(remote_dir)).is_absolute(): raise TransportError("remote sandbox workspace must be an absolute path") return ExecutionTransport( diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 09f10c4..03bb6d0 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -161,6 +161,45 @@ def test_run_execution_refuses_unknown_profile_before_sandbox() -> None: manager.create.assert_not_called() +def test_run_execution_refuses_disabled_profile_before_sandbox() -> None: + manager = MagicMock() + catalog = ProfileCatalog() + profile, _ = catalog.resolve(PROFILE) + catalog.profiles()[(profile.id, profile.version)] = profile.model_copy( + update={"status": "disabled"} + ) + + result = run_execution(_request(), catalog=catalog, rein=_FakeRein(), manager=manager) + + assert result.ok is False + assert result.evidence.outcome == "refused" + assert result.evidence.failure_stage == "resolution" + assert "profile disabled" in (result.evidence.error or "") + manager.create.assert_not_called() + + +def test_run_execution_refuses_ambiguous_profile_before_sandbox() -> None: + manager = MagicMock() + catalog = ProfileCatalog() + profile, _ = catalog.resolve(PROFILE) + catalog.profiles()[(profile.id, "2.0.0")] = profile.model_copy( + update={"version": "2.0.0"} + ) + + result = run_execution( + _request(profile=profile.id), + catalog=catalog, + rein=_FakeRein(), + manager=manager, + ) + + assert result.ok is False + assert result.evidence.outcome == "refused" + assert result.evidence.failure_stage == "resolution" + assert "pin one of" in (result.evidence.error or "") + manager.create.assert_not_called() + + def test_run_execution_refuses_blocked_profile_before_sandbox() -> None: manager = MagicMock() diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 3c426a9..356d845 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -22,6 +22,7 @@ def _profile( profile_id: str = "harness.test", version: str = "1.0.0", contract: str = "1.0", + status: str = "enabled", rein: str = "rein-test", required: str = "session_style: unattended", extra: str = "", @@ -30,7 +31,7 @@ def _profile( id: {profile_id} version: \"{version}\" contract_version: \"{contract}\" -status: enabled +status: {status} rein: id: {rein} required_capabilities: @@ -150,6 +151,26 @@ def test_unpinned_multi_version_profile_is_ambiguous(tmp_path) -> None: ProfileCatalog(profiles, reins).resolve("harness.test") +def test_disabled_revision_still_requires_pinned_selection(tmp_path) -> None: + profiles = tmp_path / "profiles" + reins = tmp_path / "reins" + profiles.mkdir() + reins.mkdir() + _write(profiles, "enabled.yaml", _profile(version="1.0.0")) + _write( + profiles, + "disabled.yaml", + _profile(version="2.0.0", status="disabled"), + ) + _write(reins, "rein.yaml", _rein()) + catalog = ProfileCatalog(profiles, reins) + + with pytest.raises(AmbiguousProfileError, match="pin one of"): + catalog.resolve("harness.test") + with pytest.raises(IncompatibleProfileError, match="profile disabled"): + catalog.resolve("harness.test@2.0.0") + + @pytest.mark.parametrize( ("profile_text", "match"), [ diff --git a/tests/test_transport.py b/tests/test_transport.py index 36845bd..b8db43d 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -49,11 +49,34 @@ def test_remote_transport_wraps_command_without_local_shell() -> None: assert transport.kind == "ssh" assert transport.command(["git", "-C", "/tmp/sbx", "status"]) == [ "ssh", + "--", "agent@sandboxer01", "sh -c 'cd \"$1\" && shift && exec \"$@\"' sh /tmp/sbx git -C /tmp/sbx status", ] +@pytest.mark.parametrize( + "ssh_target", + ["-v", "-oProxyCommand", "agent@-host", "-agent@host", "agent@host extra"], +) +def test_remote_transport_rejects_ssh_option_and_invalid_targets(ssh_target) -> None: + sandbox = SandboxHandle( + sandbox_id="sbx", + host="sandboxer01", + reachability={"ssh": ssh_target, "remote_dir": "/tmp/sbx"}, + ) + + with pytest.raises(TransportError, match="single non-option host"): + transport_from_sandbox(sandbox) + + +def test_direct_remote_transport_cannot_bypass_ssh_target_validation() -> None: + transport = ExecutionTransport(kind="ssh", workspace="/tmp/sbx", ssh_target="-v") + + with pytest.raises(TransportError, match="single non-option host"): + transport.command(["true"]) + + @pytest.mark.parametrize( "reachability", [ @@ -103,10 +126,10 @@ def test_remote_task_file_uses_ssh_stdin_and_cleanup() -> None: transport.remove_file(task_path) create = run.call_args_list[0] - assert create.args[0][:2] == ["ssh", "agent@sandboxer01"] + assert create.args[0][:3] == ["ssh", "--", "agent@sandboxer01"] assert create.kwargs["input"] == '{"title": "bounded"}' - assert "cat >" in create.args[0][2] - assert "rm -f" in run.call_args_list[1].args[0][2] + assert "cat >" in create.args[0][3] + assert "rm -f" in run.call_args_list[1].args[0][3] def test_resolve_executable_fails_inside_transport() -> None: diff --git a/workplans/GLAS-WP-0010-transport-and-resolution-hardening.md b/workplans/GLAS-WP-0010-transport-and-resolution-hardening.md new file mode 100644 index 0000000..81d346e --- /dev/null +++ b/workplans/GLAS-WP-0010-transport-and-resolution-hardening.md @@ -0,0 +1,66 @@ +--- +id: GLAS-WP-0010 +type: workplan +title: "Harden SSH transport and profile refusal coverage" +domain: infotech +repo: glas-harness +status: finished +owner: codex +topic_slug: transport-and-resolution-hardening +created: "2026-08-23" +updated: "2026-08-23" +--- + +# Harden SSH transport and profile refusal coverage + +## Reject option-like SSH targets + +```task +id: GLAS-WP-0010-T01 +status: done +priority: high +``` + +Require the optional SSH user and host components to begin with an +alphanumeric character, terminate SSH option parsing explicitly, and cover +both descriptor validation and direct transport construction with regression +tests. + +**Completed 2026-08-23:** SSH user and host components must now start with an +alphanumeric character, the constructed command includes the `--` option +terminator, and both factory-created and directly constructed transports reject +option-like targets. Focused transport tests pass. + +## Pin multi-revision profile selection semantics + +```task +id: GLAS-WP-0010-T02 +status: done +priority: medium +``` + +Retain the safer resolver behavior in which every revision, including a +disabled revision, makes an unpinned selector ambiguous. Correct the profile +documentation and add mixed enabled/disabled coverage. + +**Completed 2026-08-23:** documentation now matches the existing fail-closed +resolver: every revision participates in unpinned ambiguity, while a pinned +disabled revision refuses. A mixed enabled/disabled catalog test locks in both +properties. + +## Prove pre-sandbox refusal and publish + +```task +id: GLAS-WP-0010-T03 +status: done +priority: high +``` + +Add gateway tests proving disabled and ambiguous selections cannot call +sand-boxer, run the full suite and catalog validation, synchronize the +workplan, publish, and retain any actionable leftovers as live residuals. + +**Completed 2026-08-23:** real-catalog gateway tests prove disabled and +ambiguous selectors return normalized resolution refusals without calling +sand-boxer. All 72 tests, catalog validation, and whitespace checks pass. No +actionable leftover was discovered, so no residual was created.