Harden SSH and profile resolution boundaries
Some checks failed
ci / validate (push) Has been cancelled
Some checks failed
ci / validate (push) Has been cancelled
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a0233b-178d-7162-b92f-31a31ea8ca9b
This commit is contained in:
parent
60564fda68
commit
695438019c
8 changed files with 176 additions and 10 deletions
|
|
@ -18,6 +18,7 @@
|
||||||
| workplan | GLAS-WP-0007 | finished | — | workplans/GLAS-WP-0007-capability-scope-truth.md |
|
| 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-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-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-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||||
| task | GLAS-0001-T02 | 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 |
|
| 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-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-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-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-0001 | done | blue | docs/intakes/residuals.md |
|
||||||
| intake | GLAS-IN-0002 | todo | red | docs/intakes/residuals.md |
|
| intake | GLAS-IN-0002 | todo | red | docs/intakes/residuals.md |
|
||||||
| intake | GLAS-IN-0003 | todo | green | docs/intakes/residuals.md |
|
| intake | GLAS-IN-0003 | todo | green | docs/intakes/residuals.md |
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ Each YAML document under `profiles/` declares:
|
||||||
The catalog discovers files deterministically, forbids unknown schema fields,
|
The catalog discovers files deterministically, forbids unknown schema fields,
|
||||||
rejects duplicate revisions, and checks the selected profile against
|
rejects duplicate revisions, and checks the selected profile against
|
||||||
`registry/reins/*.yaml`. Unpinned lookup is accepted only when exactly one
|
`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.
|
Use `glas-harness profiles` as the catalog/packaging validation command.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
and its dependencies available inside that transport; host-only installation is
|
||||||
not treated as sandbox availability.
|
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
|
There is no production default rein. Direct `Rein` injection remains a narrow
|
||||||
library/test seam but still requires a valid profile so profile, sandbox,
|
library/test seam but still requires a valid profile so profile, sandbox,
|
||||||
model, tool policy, and evidence are explicit.
|
model, tool policy, and evidence are explicit.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ class TransportError(RuntimeError):
|
||||||
"""The sandbox reachability descriptor cannot safely execute commands."""
|
"""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)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -63,9 +65,11 @@ class ExecutionTransport:
|
||||||
*scoped_command,
|
*scoped_command,
|
||||||
]
|
]
|
||||||
if self.kind == "ssh":
|
if self.kind == "ssh":
|
||||||
if not self.ssh_target:
|
if not self.ssh_target or not _SSH_TARGET.fullmatch(self.ssh_target):
|
||||||
raise TransportError("SSH transport is missing target")
|
raise TransportError(
|
||||||
return ["ssh", self.ssh_target, shlex.join(scoped_command)]
|
"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}")
|
raise TransportError(f"unsupported execution transport: {self.kind}")
|
||||||
|
|
||||||
def run(
|
def run(
|
||||||
|
|
@ -175,7 +179,9 @@ def transport_from_sandbox(sandbox: SandboxHandle) -> ExecutionTransport:
|
||||||
"incomplete remote sandbox reachability: ssh and remote_dir are required"
|
"incomplete remote sandbox reachability: ssh and remote_dir are required"
|
||||||
)
|
)
|
||||||
if not _SSH_TARGET.fullmatch(str(ssh_target)):
|
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():
|
if not PurePosixPath(str(remote_dir)).is_absolute():
|
||||||
raise TransportError("remote sandbox workspace must be an absolute path")
|
raise TransportError("remote sandbox workspace must be an absolute path")
|
||||||
return ExecutionTransport(
|
return ExecutionTransport(
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,45 @@ def test_run_execution_refuses_unknown_profile_before_sandbox() -> None:
|
||||||
manager.create.assert_not_called()
|
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:
|
def test_run_execution_refuses_blocked_profile_before_sandbox() -> None:
|
||||||
manager = MagicMock()
|
manager = MagicMock()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ def _profile(
|
||||||
profile_id: str = "harness.test",
|
profile_id: str = "harness.test",
|
||||||
version: str = "1.0.0",
|
version: str = "1.0.0",
|
||||||
contract: str = "1.0",
|
contract: str = "1.0",
|
||||||
|
status: str = "enabled",
|
||||||
rein: str = "rein-test",
|
rein: str = "rein-test",
|
||||||
required: str = "session_style: unattended",
|
required: str = "session_style: unattended",
|
||||||
extra: str = "",
|
extra: str = "",
|
||||||
|
|
@ -30,7 +31,7 @@ def _profile(
|
||||||
id: {profile_id}
|
id: {profile_id}
|
||||||
version: \"{version}\"
|
version: \"{version}\"
|
||||||
contract_version: \"{contract}\"
|
contract_version: \"{contract}\"
|
||||||
status: enabled
|
status: {status}
|
||||||
rein:
|
rein:
|
||||||
id: {rein}
|
id: {rein}
|
||||||
required_capabilities:
|
required_capabilities:
|
||||||
|
|
@ -150,6 +151,26 @@ def test_unpinned_multi_version_profile_is_ambiguous(tmp_path) -> None:
|
||||||
ProfileCatalog(profiles, reins).resolve("harness.test")
|
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(
|
@pytest.mark.parametrize(
|
||||||
("profile_text", "match"),
|
("profile_text", "match"),
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,34 @@ def test_remote_transport_wraps_command_without_local_shell() -> None:
|
||||||
assert transport.kind == "ssh"
|
assert transport.kind == "ssh"
|
||||||
assert transport.command(["git", "-C", "/tmp/sbx", "status"]) == [
|
assert transport.command(["git", "-C", "/tmp/sbx", "status"]) == [
|
||||||
"ssh",
|
"ssh",
|
||||||
|
"--",
|
||||||
"agent@sandboxer01",
|
"agent@sandboxer01",
|
||||||
"sh -c 'cd \"$1\" && shift && exec \"$@\"' sh /tmp/sbx git -C /tmp/sbx status",
|
"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(
|
@pytest.mark.parametrize(
|
||||||
"reachability",
|
"reachability",
|
||||||
[
|
[
|
||||||
|
|
@ -103,10 +126,10 @@ def test_remote_task_file_uses_ssh_stdin_and_cleanup() -> None:
|
||||||
transport.remove_file(task_path)
|
transport.remove_file(task_path)
|
||||||
|
|
||||||
create = run.call_args_list[0]
|
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 create.kwargs["input"] == '{"title": "bounded"}'
|
||||||
assert "cat >" in create.args[0][2]
|
assert "cat >" in create.args[0][3]
|
||||||
assert "rm -f" in run.call_args_list[1].args[0][2]
|
assert "rm -f" in run.call_args_list[1].args[0][3]
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_executable_fails_inside_transport() -> None:
|
def test_resolve_executable_fails_inside_transport() -> None:
|
||||||
|
|
|
||||||
66
workplans/GLAS-WP-0010-transport-and-resolution-hardening.md
Normal file
66
workplans/GLAS-WP-0010-transport-and-resolution-hardening.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue