@mriekki/project-forge 4.24.1 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/components.json +4 -4
- package/assets/deployment/distribution.json +3 -0
- package/assets/generated/ASSEMBLY.json +29 -14
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +70 -1
- package/assets/generated/core/core/skills/deliver/SKILL.md +7 -1
- package/assets/generated/core/core/skills/deploy/SKILL.md +24 -1
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +40 -1
- package/assets/generated/core/core/skills/setup/SKILL.md +20 -1
- package/assets/generated/deployment/src/project_forge_deployment/agent_session.py +154 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +31 -1
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +24 -0
- package/assets/generated/deployment/src/project_forge_deployment/cli.py +82 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_setup.py +21 -1
- package/assets/generated/deployment/src/project_forge_deployment/proxy_peer.py +168 -0
- package/assets/generated/deployment/src/project_forge_deployment/safe_diagnostics.py +172 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +52 -4
- package/package.json +1 -1
|
@@ -250,7 +250,31 @@ upgrade_platform() {
|
|
|
250
250
|
rm -rf "$candidate_dir" "$backup_dir"
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
# PF-06: read the exact managed Caddy container/network identity.
|
|
254
|
+
# Only selected fields are returned; no Config.Env, mounts or provider secrets.
|
|
255
|
+
# This cannot make an application accept a changed proxy peer.
|
|
256
|
+
proxy_peer_status() {
|
|
257
|
+
local id name state image network_id networks members
|
|
258
|
+
id="$(docker inspect --format '{{.Id}}' "$CADDY_CONTAINER" 2>/dev/null)" || {
|
|
259
|
+
echo "Managed proxy container is missing or unavailable." >&2; exit 31;
|
|
260
|
+
}
|
|
261
|
+
name="$(docker inspect --format '{{.Name}}' "$CADDY_CONTAINER" 2>/dev/null)" || exit 31
|
|
262
|
+
name="${name#/}"
|
|
263
|
+
state="$(docker inspect --format '{{.State.Status}}' "$CADDY_CONTAINER" 2>/dev/null)" || exit 31
|
|
264
|
+
image="$(docker inspect --format '{{.Config.Image}}' "$CADDY_CONTAINER" 2>/dev/null)" || exit 31
|
|
265
|
+
network_id="$(docker network inspect --format '{{.Id}}' "$NETWORK_NAME" 2>/dev/null)" || {
|
|
266
|
+
echo "Managed proxy network is missing or unavailable." >&2; exit 32;
|
|
267
|
+
}
|
|
268
|
+
networks="$(docker inspect --format '{{json .NetworkSettings.Networks}}' "$CADDY_CONTAINER" 2>/dev/null)" || exit 33
|
|
269
|
+
members="$(docker network inspect --format '{{json .Containers}}' "$NETWORK_NAME" 2>/dev/null)" || exit 33
|
|
270
|
+
printf 'containerId=%s\ncontainerName=%s\ncontainerState=%s\ncontainerImage=%s\nnetworkId=%s\nnetworks=%s\nmembers=%s\n' \
|
|
271
|
+
"$id" "$name" "$state" "$image" "$network_id" "$networks" "$members"
|
|
272
|
+
}
|
|
273
|
+
|
|
253
274
|
case "$ACTION" in
|
|
275
|
+
proxy-peer)
|
|
276
|
+
proxy_peer_status
|
|
277
|
+
;;
|
|
254
278
|
status)
|
|
255
279
|
status
|
|
256
280
|
;;
|
|
@@ -12,6 +12,7 @@ from .box_platform import (
|
|
|
12
12
|
apply_platform_change,
|
|
13
13
|
plan_platform_change,
|
|
14
14
|
platform_doctor,
|
|
15
|
+
platform_proxy_peer,
|
|
15
16
|
platform_status,
|
|
16
17
|
)
|
|
17
18
|
from .doctor import target_doctor, workstation_doctor
|
|
@@ -230,6 +231,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
230
231
|
agent_add.add_argument("name")
|
|
231
232
|
agent_add.add_argument("--yes", action="store_true")
|
|
232
233
|
|
|
234
|
+
agent_session = target_sub.add_parser(
|
|
235
|
+
"agent-session",
|
|
236
|
+
help="Reuse a workstation SSH agent/key and run one normal Deployment Kit command",
|
|
237
|
+
)
|
|
238
|
+
agent_session.add_argument("name", help="Exact configured SSH target identity to load")
|
|
239
|
+
agent_session.add_argument(
|
|
240
|
+
"session_args", nargs=argparse.REMAINDER,
|
|
241
|
+
help="After --, a normal Deployment Kit command (with its usual authorization gates)",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
agent_run = target_sub.add_parser(
|
|
245
|
+
"agent-run",
|
|
246
|
+
help="Run one repository-defined local program with the target's SSH agent, no shell",
|
|
247
|
+
)
|
|
248
|
+
agent_run.add_argument("name", help="Exact configured SSH target identity to load")
|
|
249
|
+
agent_run.add_argument(
|
|
250
|
+
"run_args", nargs=argparse.REMAINDER,
|
|
251
|
+
help="After --, exact executable and arguments for a normal local program",
|
|
252
|
+
)
|
|
253
|
+
|
|
233
254
|
trust_candidates = target_sub.add_parser(
|
|
234
255
|
"trust-candidates",
|
|
235
256
|
help="Read candidate host-key fingerprints; candidates are never trusted by discovery",
|
|
@@ -320,6 +341,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
320
341
|
)
|
|
321
342
|
platform_doctor_parser.add_argument("name")
|
|
322
343
|
platform_doctor_parser.add_argument("--json", action="store_true")
|
|
344
|
+
proxy_peer = platform_sub.add_parser(
|
|
345
|
+
"peer-inspect",
|
|
346
|
+
help="Read exact managed Caddy peer identity and review required application convergence",
|
|
347
|
+
)
|
|
348
|
+
proxy_peer.add_argument("name")
|
|
349
|
+
proxy_peer.add_argument("--configured-peer", help="Exact peer IP currently trusted by this application")
|
|
350
|
+
proxy_peer.add_argument("--previous-container-id", help="Previously reviewed Caddy container ID")
|
|
351
|
+
proxy_peer.add_argument("--previous-network-id", help="Previously reviewed Docker network ID")
|
|
352
|
+
proxy_peer.add_argument("--json", action="store_true")
|
|
323
353
|
for command in ("install", "upgrade"):
|
|
324
354
|
change = platform_sub.add_parser(
|
|
325
355
|
command,
|
|
@@ -373,6 +403,47 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
373
403
|
if args.group == "target" and args.command == "agent-status":
|
|
374
404
|
_print(agent_status(load_profile(args.name)), as_json=args.json)
|
|
375
405
|
return 0
|
|
406
|
+
if args.group == "target" and args.command == "agent-run":
|
|
407
|
+
import subprocess
|
|
408
|
+
|
|
409
|
+
command = list(args.run_args)
|
|
410
|
+
if command and command[0] == "--":
|
|
411
|
+
command.pop(0)
|
|
412
|
+
if not command:
|
|
413
|
+
raise DeploymentKitError(
|
|
414
|
+
"Supply an exact local executable and argv after '--'. "
|
|
415
|
+
"The agent-run command does not authorize the program's writes."
|
|
416
|
+
)
|
|
417
|
+
from .agent_session import target_agent_session
|
|
418
|
+
|
|
419
|
+
with target_agent_session(load_profile(args.name)) as session:
|
|
420
|
+
print(
|
|
421
|
+
f"SSH agent ready for {session['targetName']} ({session['agent']}); "
|
|
422
|
+
"the launched program retains its own authorization gates.",
|
|
423
|
+
file=sys.stderr,
|
|
424
|
+
)
|
|
425
|
+
# No shell, eval, redirection, or implicit --yes. The caller
|
|
426
|
+
# selects an already-reviewed repository-owned program.
|
|
427
|
+
return subprocess.run(command, check=False).returncode
|
|
428
|
+
if args.group == "target" and args.command == "agent-session":
|
|
429
|
+
command = list(args.session_args)
|
|
430
|
+
if command and command[0] == "--":
|
|
431
|
+
command.pop(0)
|
|
432
|
+
if not command or command[:2] == ["target", "agent-session"]:
|
|
433
|
+
raise DeploymentKitError(
|
|
434
|
+
"Supply a non-nested Deployment Kit subcommand after '--'. "
|
|
435
|
+
"The SSH-agent session does not authorize remote operations."
|
|
436
|
+
)
|
|
437
|
+
from .agent_session import target_agent_session
|
|
438
|
+
from .entrypoint import main as dispatch
|
|
439
|
+
|
|
440
|
+
with target_agent_session(load_profile(args.name)) as session:
|
|
441
|
+
print(
|
|
442
|
+
f"SSH agent ready for {session['targetName']} ({session['agent']}); "
|
|
443
|
+
"normal CLI checks and write gates still apply.",
|
|
444
|
+
file=sys.stderr,
|
|
445
|
+
)
|
|
446
|
+
return dispatch(command)
|
|
376
447
|
if args.group == "target" and args.command == "agent-add":
|
|
377
448
|
profile = load_profile(args.name)
|
|
378
449
|
status = agent_status(profile)
|
|
@@ -425,6 +496,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
425
496
|
if args.group == "platform" and args.command == "doctor":
|
|
426
497
|
_print(platform_doctor(args.name), as_json=args.json)
|
|
427
498
|
return 0
|
|
499
|
+
if args.group == "platform" and args.command == "peer-inspect":
|
|
500
|
+
_print(
|
|
501
|
+
platform_proxy_peer(
|
|
502
|
+
args.name,
|
|
503
|
+
configured_peer=args.configured_peer,
|
|
504
|
+
previous_container_id=args.previous_container_id,
|
|
505
|
+
previous_network_id=args.previous_network_id,
|
|
506
|
+
),
|
|
507
|
+
as_json=args.json,
|
|
508
|
+
)
|
|
509
|
+
return 0
|
|
428
510
|
if args.group == "platform" and args.command in {"install", "upgrade"}:
|
|
429
511
|
return _platform_change(args)
|
|
430
512
|
raise DeploymentKitError("Unsupported command.")
|
|
@@ -15,6 +15,7 @@ from .environment_probe import plan_environment_proof
|
|
|
15
15
|
from .errors import DeploymentKitError
|
|
16
16
|
from .profiles import load_profile
|
|
17
17
|
from .proof_receipt import load_environment_proof_receipt
|
|
18
|
+
from .safe_diagnostics import project_setup_summary
|
|
18
19
|
from .ssh import diagnose_access
|
|
19
20
|
|
|
20
21
|
|
|
@@ -126,6 +127,21 @@ def _access_recovery(status: dict[str, Any]) -> dict[str, Any]:
|
|
|
126
127
|
"Do not reinstall keys repeatedly, enable password SSH, widen AllowUsers, or weaken host verification implicitly."
|
|
127
128
|
),
|
|
128
129
|
}
|
|
130
|
+
if category == "local-signing-unproven":
|
|
131
|
+
return {
|
|
132
|
+
"kind": "local-prerequisite",
|
|
133
|
+
"action": "use-existing-target-agent-session",
|
|
134
|
+
"command": (
|
|
135
|
+
f"project-forge-deploy target agent-session {target} "
|
|
136
|
+
f"-- environment setup status {target} --json"
|
|
137
|
+
),
|
|
138
|
+
"reason": detail.get("guidance"),
|
|
139
|
+
"safety": (
|
|
140
|
+
"Prepare the configured local signer using the existing supported "
|
|
141
|
+
"agent-session helper in a trusted interactive terminal. "
|
|
142
|
+
"No remote SSH policy change, new key or trust alteration is authorized."
|
|
143
|
+
),
|
|
144
|
+
}
|
|
129
145
|
if category == "local-signing-failed":
|
|
130
146
|
return {
|
|
131
147
|
"kind": "local-repair",
|
|
@@ -362,8 +378,12 @@ def remote_setup_plan(
|
|
|
362
378
|
"previousProof": receipt,
|
|
363
379
|
}
|
|
364
380
|
|
|
365
|
-
|
|
381
|
+
result = {
|
|
366
382
|
**status,
|
|
367
383
|
"next": next_action,
|
|
368
384
|
"mutated": False,
|
|
369
385
|
}
|
|
386
|
+
# Additive safe, bounded, versioned projection. Retain the full evidence
|
|
387
|
+
# in its existing owner; never echo arbitrary remote stderr to summaries.
|
|
388
|
+
result["diagnosticSummary"] = project_setup_summary(result)
|
|
389
|
+
return result
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Read-only exact Caddy peer observation and reconciliation proposal (PF-06).
|
|
2
|
+
|
|
3
|
+
No Docker/network/provider writes, no configuration mutation, no trust bypass.
|
|
4
|
+
A completed observation is an input to a separately reviewed application
|
|
5
|
+
convergence plan, never automatic authorization to rewrite trusted peer IPs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .box_platform_assets import CADDY_CONTAINER, CADDY_IMAGE, NETWORK_NAME
|
|
16
|
+
from .errors import DeploymentKitError
|
|
17
|
+
|
|
18
|
+
_SCHEMA_VERSION = 1
|
|
19
|
+
_HEX_ID = re.compile(r"[0-9a-f]{64}")
|
|
20
|
+
_KEYS = frozenset({
|
|
21
|
+
"containerId", "containerName", "containerState", "containerImage",
|
|
22
|
+
"networkId", "networks", "members",
|
|
23
|
+
})
|
|
24
|
+
_MAX_BYTES = 65536
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _require_exact_ip(raw: Any) -> str:
|
|
28
|
+
if not isinstance(raw, str):
|
|
29
|
+
raise DeploymentKitError("Managed proxy address is not an IPv4 string.")
|
|
30
|
+
try:
|
|
31
|
+
address = ipaddress.IPv4Address(raw)
|
|
32
|
+
except ipaddress.AddressValueError as error:
|
|
33
|
+
raise DeploymentKitError("Managed proxy address is invalid.") from error
|
|
34
|
+
if not address.is_private or address.is_unspecified or address.is_loopback or address.is_multicast or address.is_link_local:
|
|
35
|
+
raise DeploymentKitError("Managed proxy address is not a suitable private-network peer.")
|
|
36
|
+
return str(address)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_peer_observation(stdout: str) -> dict[str, Any]:
|
|
40
|
+
"""Validate an allowlisted remote observation without propagating raw output."""
|
|
41
|
+
if not isinstance(stdout, str) or len(stdout.encode("utf-8")) > _MAX_BYTES:
|
|
42
|
+
raise DeploymentKitError("Managed proxy observation exceeds the bounded read-only format.")
|
|
43
|
+
fields: dict[str, str] = {}
|
|
44
|
+
for line in stdout.splitlines():
|
|
45
|
+
if "=" not in line:
|
|
46
|
+
raise DeploymentKitError("Malformed managed proxy observation.")
|
|
47
|
+
name, value = line.split("=", 1)
|
|
48
|
+
if name not in _KEYS or name in fields or not value:
|
|
49
|
+
raise DeploymentKitError("Unexpected or duplicate managed proxy observation field.")
|
|
50
|
+
fields[name] = value
|
|
51
|
+
if set(fields) != _KEYS:
|
|
52
|
+
raise DeploymentKitError("Managed proxy observation is incomplete.")
|
|
53
|
+
|
|
54
|
+
container_id, network_id = fields["containerId"], fields["networkId"]
|
|
55
|
+
if not _HEX_ID.fullmatch(container_id) or not _HEX_ID.fullmatch(network_id):
|
|
56
|
+
raise DeploymentKitError("Managed proxy identity is not an exact full Docker identifier.")
|
|
57
|
+
if fields["containerName"] != CADDY_CONTAINER or fields["containerState"] != "running":
|
|
58
|
+
raise DeploymentKitError("Expected managed proxy container is not running.")
|
|
59
|
+
if fields["containerImage"] != CADDY_IMAGE:
|
|
60
|
+
raise DeploymentKitError("Managed proxy image differs from the pinned Kit image.")
|
|
61
|
+
try:
|
|
62
|
+
networks = json.loads(fields["networks"])
|
|
63
|
+
members = json.loads(fields["members"])
|
|
64
|
+
except json.JSONDecodeError as error:
|
|
65
|
+
raise DeploymentKitError("Managed proxy Docker network observation is malformed.") from error
|
|
66
|
+
if not isinstance(networks, dict) or set(networks) != {NETWORK_NAME}:
|
|
67
|
+
raise DeploymentKitError("Managed proxy has an unexpected network attachment.")
|
|
68
|
+
net = networks[NETWORK_NAME]
|
|
69
|
+
if not isinstance(net, dict) or net.get("NetworkID") != network_id:
|
|
70
|
+
raise DeploymentKitError("Managed proxy network identity mismatch.")
|
|
71
|
+
address = _require_exact_ip(net.get("IPAddress"))
|
|
72
|
+
if not isinstance(members, dict) or container_id not in members:
|
|
73
|
+
raise DeploymentKitError("Managed proxy is missing from the named network member registry.")
|
|
74
|
+
member = members[container_id]
|
|
75
|
+
if not isinstance(member, dict) or member.get("Name") != CADDY_CONTAINER:
|
|
76
|
+
raise DeploymentKitError("Managed proxy membership does not match its exact identity.")
|
|
77
|
+
cidr = member.get("IPv4Address")
|
|
78
|
+
if not isinstance(cidr, str) or cidr.split("/", 1)[0] != address:
|
|
79
|
+
raise DeploymentKitError("Managed proxy IP disagrees with Docker network membership.")
|
|
80
|
+
# The network may contain unrelated application peers. Do not trust the
|
|
81
|
+
# entire network, require its exclusivity, or grant those peers authority.
|
|
82
|
+
for other_id, other in members.items():
|
|
83
|
+
if not isinstance(other_id, str) or not _HEX_ID.fullmatch(other_id):
|
|
84
|
+
raise DeploymentKitError("Managed network member identity is malformed.")
|
|
85
|
+
if not isinstance(other, dict):
|
|
86
|
+
raise DeploymentKitError("Managed network member record is malformed.")
|
|
87
|
+
other_ip = other.get("IPv4Address")
|
|
88
|
+
if not isinstance(other_ip, str):
|
|
89
|
+
raise DeploymentKitError("Managed network member address is malformed.")
|
|
90
|
+
if other_id != container_id and other_ip.split("/", 1)[0] == address:
|
|
91
|
+
raise DeploymentKitError("Managed proxy address conflicts with another network member.")
|
|
92
|
+
return {
|
|
93
|
+
"schemaVersion": _SCHEMA_VERSION,
|
|
94
|
+
"containerName": CADDY_CONTAINER,
|
|
95
|
+
"containerId": container_id,
|
|
96
|
+
"containerImage": CADDY_IMAGE,
|
|
97
|
+
"networkName": NETWORK_NAME,
|
|
98
|
+
"networkId": network_id,
|
|
99
|
+
"peerIp": address,
|
|
100
|
+
"verifiedBy": "read-only-docker-observation",
|
|
101
|
+
"mutated": False,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def assess_configured_peer(
|
|
106
|
+
observation: dict[str, Any],
|
|
107
|
+
configured_peer: str | None,
|
|
108
|
+
*,
|
|
109
|
+
previous_container_id: str | None = None,
|
|
110
|
+
previous_network_id: str | None = None,
|
|
111
|
+
) -> dict[str, Any]:
|
|
112
|
+
"""Produce a no-write convergence decision requiring separate review."""
|
|
113
|
+
if not isinstance(observation, dict) or observation.get("schemaVersion") != _SCHEMA_VERSION:
|
|
114
|
+
raise DeploymentKitError("Managed proxy observation schema is unsupported.")
|
|
115
|
+
required = {
|
|
116
|
+
"schemaVersion", "containerName", "containerId", "containerImage",
|
|
117
|
+
"networkName", "networkId", "peerIp", "verifiedBy", "mutated",
|
|
118
|
+
}
|
|
119
|
+
if set(observation) != required or observation.get("mutated") is not False or (
|
|
120
|
+
observation.get("verifiedBy") != "read-only-docker-observation"
|
|
121
|
+
):
|
|
122
|
+
raise DeploymentKitError("Untrusted managed proxy observation.")
|
|
123
|
+
for id_value in (observation["containerId"], observation["networkId"]):
|
|
124
|
+
if not isinstance(id_value, str) or not _HEX_ID.fullmatch(id_value):
|
|
125
|
+
raise DeploymentKitError("Managed proxy observation lacks exact identity.")
|
|
126
|
+
observed = _require_exact_ip(observation["peerIp"])
|
|
127
|
+
if (observation["containerName"] != CADDY_CONTAINER or
|
|
128
|
+
observation["containerImage"] != CADDY_IMAGE or
|
|
129
|
+
observation["networkName"] != NETWORK_NAME):
|
|
130
|
+
raise DeploymentKitError("Managed proxy observation is for a different owned component.")
|
|
131
|
+
|
|
132
|
+
if configured_peer is not None:
|
|
133
|
+
configured_peer = _require_exact_ip(configured_peer)
|
|
134
|
+
for prior in (previous_container_id, previous_network_id):
|
|
135
|
+
if prior is not None and (not isinstance(prior, str) or not _HEX_ID.fullmatch(prior)):
|
|
136
|
+
raise DeploymentKitError("Prior peer identity must be the exact reviewed Docker ID.")
|
|
137
|
+
changed = (
|
|
138
|
+
(configured_peer is not None and configured_peer != observed) or
|
|
139
|
+
(previous_container_id is not None and previous_container_id != observation["containerId"]) or
|
|
140
|
+
(previous_network_id is not None and previous_network_id != observation["networkId"])
|
|
141
|
+
)
|
|
142
|
+
state = (
|
|
143
|
+
"requires-reviewed-convergence" if changed
|
|
144
|
+
else "needs-explicit-initial-binding" if configured_peer is None
|
|
145
|
+
else "needs-reviewed-identity-baseline" if (
|
|
146
|
+
previous_container_id is None or previous_network_id is None
|
|
147
|
+
)
|
|
148
|
+
else "observed-match"
|
|
149
|
+
)
|
|
150
|
+
return {
|
|
151
|
+
**observation,
|
|
152
|
+
"state": state,
|
|
153
|
+
"configuredPeerIp": configured_peer,
|
|
154
|
+
"nextAction": (
|
|
155
|
+
"review-peer-and-application-configuration" if changed
|
|
156
|
+
else "review-initial-peer-binding" if configured_peer is None
|
|
157
|
+
else "record-reviewed-peer-identities" if state == "needs-reviewed-identity-baseline"
|
|
158
|
+
else "retain-reviewed-peer-binding"
|
|
159
|
+
),
|
|
160
|
+
"requiresAuthorization": state != "observed-match",
|
|
161
|
+
"doesNotProve": [
|
|
162
|
+
"application trust middleware enforcement",
|
|
163
|
+
"live public-origin request contract",
|
|
164
|
+
"exclusive Docker network ownership",
|
|
165
|
+
"future Docker address stability",
|
|
166
|
+
],
|
|
167
|
+
"mutated": False,
|
|
168
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Small, fixed-field, secret-free diagnostic summaries for exact-target setup.
|
|
2
|
+
|
|
3
|
+
This is an additive v1 projection. Full reviewed plans and evidence stay in
|
|
4
|
+
their canonical storage. Never reflect arbitrary stderr, provider responses,
|
|
5
|
+
host exception text, paths, URLs or untrusted string fields into this summary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DIAGNOSTIC_SCHEMA = 1
|
|
16
|
+
CAPABILITY = "remote-environment-setup"
|
|
17
|
+
CAPABILITY_VERSION = 1
|
|
18
|
+
SUMMARY_MAX_BYTES = 8192
|
|
19
|
+
|
|
20
|
+
_PHASES = (
|
|
21
|
+
"workspace",
|
|
22
|
+
"targetDefinition",
|
|
23
|
+
"targetAccess",
|
|
24
|
+
"hostPlatform",
|
|
25
|
+
"deploymentPath",
|
|
26
|
+
"application",
|
|
27
|
+
)
|
|
28
|
+
_ALLOWED_CODES = frozenset({
|
|
29
|
+
"missing-identity",
|
|
30
|
+
"missing-trust",
|
|
31
|
+
"local-security-or-trust-invalid",
|
|
32
|
+
"name-resolution-failed",
|
|
33
|
+
"transport-failed",
|
|
34
|
+
"host-identity-rejected",
|
|
35
|
+
"local-signing-failed",
|
|
36
|
+
"local-signing-unproven",
|
|
37
|
+
"remote-authentication-rejected",
|
|
38
|
+
"remote-account-policy-rejected", # legacy consumers; not inferred from key offer
|
|
39
|
+
"authentication-rejected",
|
|
40
|
+
"ssh-verification-failed",
|
|
41
|
+
"unclassified-failure",
|
|
42
|
+
})
|
|
43
|
+
_ALLOWED_ACTIONS = frozenset({
|
|
44
|
+
"remote-environment-ready",
|
|
45
|
+
"configure-target-profile",
|
|
46
|
+
"create-or-select-key",
|
|
47
|
+
"verify-and-enroll-host-trust",
|
|
48
|
+
"set-verified-address",
|
|
49
|
+
"repair-target-profile",
|
|
50
|
+
"inspect-remote-account-access-policy",
|
|
51
|
+
"repair-local-key-signing",
|
|
52
|
+
"use-existing-target-agent-session",
|
|
53
|
+
"reverify-host-identity",
|
|
54
|
+
"repair-target-address-or-network-path",
|
|
55
|
+
"diagnose-target-access",
|
|
56
|
+
"install-host-platform",
|
|
57
|
+
"upgrade-host-platform",
|
|
58
|
+
"inspect-host-platform",
|
|
59
|
+
"plan-deployment-path-proof",
|
|
60
|
+
"review-deployment-path-proof",
|
|
61
|
+
"resolve-environment-isolation-collision",
|
|
62
|
+
})
|
|
63
|
+
_ACTION_KIND = frozenset({
|
|
64
|
+
"complete",
|
|
65
|
+
"read-only",
|
|
66
|
+
"local-prerequisite",
|
|
67
|
+
"local-repair",
|
|
68
|
+
"operator-action-required",
|
|
69
|
+
"human-verification",
|
|
70
|
+
"missing-facts",
|
|
71
|
+
"blocked",
|
|
72
|
+
"write-gated",
|
|
73
|
+
"proof-context-required",
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _safe_target(value: object) -> str:
|
|
78
|
+
# A selected local *logical name* only; never include hostnames, IPs,
|
|
79
|
+
# filesystem paths, URLs or provider identifiers by default.
|
|
80
|
+
if (not isinstance(value, str) or "." in value or
|
|
81
|
+
re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", value) is None):
|
|
82
|
+
return "unknown-target"
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _dimension_state(value: str) -> str:
|
|
87
|
+
if value in {"ready", "proven"}:
|
|
88
|
+
return "proved"
|
|
89
|
+
if value in {"not-proven", "not-part-of-setup"}:
|
|
90
|
+
return "notChecked"
|
|
91
|
+
if value.startswith("blocked") or value == "needs-profile":
|
|
92
|
+
return "blocked"
|
|
93
|
+
return "failed"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def project_setup_summary(
|
|
97
|
+
result: dict[str, Any], *, observed_at: datetime | None = None
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
"""Return an allowlisted, byte-bounded projection of remote setup evidence.
|
|
100
|
+
|
|
101
|
+
Unknown states or reasons must not be reported as success. No arbitrary
|
|
102
|
+
nested data, messages or paths are copied from the untrusted input.
|
|
103
|
+
"""
|
|
104
|
+
stamp = observed_at if observed_at is not None else datetime.now(timezone.utc)
|
|
105
|
+
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
|
106
|
+
raise ValueError("Observed time must include a timezone.")
|
|
107
|
+
phases = result.get("phases")
|
|
108
|
+
if not isinstance(phases, dict):
|
|
109
|
+
phases = {}
|
|
110
|
+
dimensions: dict[str, list[str]] = {
|
|
111
|
+
"proved": [], "failed": [], "blocked": [], "notChecked": []
|
|
112
|
+
}
|
|
113
|
+
for key in _PHASES:
|
|
114
|
+
phase = phases.get(key)
|
|
115
|
+
state = phase.get("state") if isinstance(phase, dict) else None
|
|
116
|
+
category = _dimension_state(state) if isinstance(state, str) else "notChecked"
|
|
117
|
+
dimensions[category].append(key)
|
|
118
|
+
|
|
119
|
+
next_action = result.get("next")
|
|
120
|
+
if not isinstance(next_action, dict):
|
|
121
|
+
next_action = {}
|
|
122
|
+
action = next_action.get("action")
|
|
123
|
+
action = action if isinstance(action, str) and action in _ALLOWED_ACTIONS else "unclassified-failure"
|
|
124
|
+
kind = next_action.get("kind")
|
|
125
|
+
kind = kind if isinstance(kind, str) and kind in _ACTION_KIND else "blocked"
|
|
126
|
+
# A malformed/mixed producer cannot turn failed or missing prerequisite
|
|
127
|
+
# observations into a success-shaped summary. Application is intentionally
|
|
128
|
+
# not part of foundational environment proof.
|
|
129
|
+
if action == "remote-environment-ready" and any(
|
|
130
|
+
key not in dimensions["proved"] for key in _PHASES[:-1]
|
|
131
|
+
):
|
|
132
|
+
action = "unclassified-failure"
|
|
133
|
+
kind = "blocked"
|
|
134
|
+
elif action == "unclassified-failure":
|
|
135
|
+
kind = "blocked"
|
|
136
|
+
target_access = phases.get("targetAccess", {})
|
|
137
|
+
detail = target_access.get("detail") if isinstance(target_access, dict) else None
|
|
138
|
+
failure_code = detail.get("failureCategory") if isinstance(detail, dict) else None
|
|
139
|
+
reason_code = failure_code if isinstance(failure_code, str) and failure_code in _ALLOWED_CODES else action
|
|
140
|
+
phase = result.get("currentPhase")
|
|
141
|
+
phase = phase if isinstance(phase, str) and phase in _PHASES else "unknown"
|
|
142
|
+
# Caller-reported mutation cannot be silently represented as read-only.
|
|
143
|
+
# This shape is not an authorization token or operation receipt.
|
|
144
|
+
mutated = result.get("mutated")
|
|
145
|
+
mutation = "none" if mutated is False else "unknown" if mutated is None else "occurred"
|
|
146
|
+
|
|
147
|
+
summary = {
|
|
148
|
+
"schemaVersion": DIAGNOSTIC_SCHEMA,
|
|
149
|
+
"capability": CAPABILITY,
|
|
150
|
+
"capabilityVersion": CAPABILITY_VERSION,
|
|
151
|
+
"target": _safe_target(result.get("targetName")),
|
|
152
|
+
"observedAt": stamp.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
153
|
+
"phase": phase,
|
|
154
|
+
"reasonCode": reason_code,
|
|
155
|
+
"dimensions": dimensions,
|
|
156
|
+
"mutation": mutation,
|
|
157
|
+
"nextAction": {
|
|
158
|
+
"code": action,
|
|
159
|
+
"kind": kind,
|
|
160
|
+
"consequence": (
|
|
161
|
+
"write-approval-required" if kind == "write-gated"
|
|
162
|
+
else "human-verification-required" if kind in {"human-verification", "operator-action-required"}
|
|
163
|
+
else "no-mutation"
|
|
164
|
+
),
|
|
165
|
+
},
|
|
166
|
+
"evidenceRef": None,
|
|
167
|
+
}
|
|
168
|
+
serialized = json.dumps(summary, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
169
|
+
if len(serialized) > SUMMARY_MAX_BYTES:
|
|
170
|
+
# Never truncate failed/blocked dimensions to meet the bound.
|
|
171
|
+
raise ValueError("Diagnostic summary exceeded the fixed v1 bound.")
|
|
172
|
+
return summary
|
|
@@ -10,7 +10,7 @@ from pathlib import Path
|
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
12
|
from .errors import DeploymentKitError
|
|
13
|
-
from .keys import key_fingerprint
|
|
13
|
+
from .keys import agent_status, key_fingerprint
|
|
14
14
|
from .security import check_ssh_material_security, validate_target_trust_file
|
|
15
15
|
|
|
16
16
|
|
|
@@ -195,7 +195,19 @@ def _tcp_probe(profile: dict[str, Any], address: str) -> dict[str, Any]:
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
|
|
198
|
-
def _classify_failure(
|
|
198
|
+
def _classify_failure(
|
|
199
|
+
text: str,
|
|
200
|
+
*,
|
|
201
|
+
auth: dict[str, Any],
|
|
202
|
+
offered: bool,
|
|
203
|
+
accepted: bool,
|
|
204
|
+
signer_loaded: bool | None = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Classify only what the SSH evidence proves.
|
|
207
|
+
|
|
208
|
+
``Server accepts key`` is a preliminary public-key handshake, not
|
|
209
|
+
proof of client signing or of a completed authenticated session.
|
|
210
|
+
"""
|
|
199
211
|
lower = text.lower()
|
|
200
212
|
if "remote host identification has changed" in lower or "host key verification failed" in lower:
|
|
201
213
|
return "host-identity-rejected"
|
|
@@ -213,9 +225,21 @@ def _classify_failure(text: str, *, auth: dict[str, Any], offered: bool, accepte
|
|
|
213
225
|
return "transport-failed"
|
|
214
226
|
if "permission denied" in lower:
|
|
215
227
|
if accepted:
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
# No signer proof means neither a remote account-policy refusal
|
|
229
|
+
# nor a verified credential rejection can be asserted.
|
|
230
|
+
if "sign_and_send_pubkey: signing using" in lower:
|
|
231
|
+
# A signer (agent or directly usable key) did sign, but
|
|
232
|
+
# the remote side still refused authentication.
|
|
233
|
+
return "remote-authentication-rejected"
|
|
234
|
+
# Merely observing a loaded agent is not proof that this key signed.
|
|
235
|
+
return "local-signing-unproven"
|
|
236
|
+
if offered:
|
|
237
|
+
# The server has enough evidence to refuse the offered key.
|
|
218
238
|
return "remote-authentication-rejected"
|
|
239
|
+
if auth.get("publicKeySupported") and signer_loaded is False:
|
|
240
|
+
# A publickey-capable server does not establish that this
|
|
241
|
+
# workstation could offer/sign the configured identity.
|
|
242
|
+
return "local-signing-unproven"
|
|
219
243
|
return "authentication-rejected"
|
|
220
244
|
return "ssh-verification-failed"
|
|
221
245
|
|
|
@@ -266,6 +290,7 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
266
290
|
}
|
|
267
291
|
|
|
268
292
|
fingerprint = key_fingerprint(identity)
|
|
293
|
+
local_signer: dict[str, Any] = {"state": "not-checked", "loaded": False}
|
|
269
294
|
attempts: list[dict[str, Any]] = []
|
|
270
295
|
for address in addresses:
|
|
271
296
|
tcp = _tcp_probe(profile, address)
|
|
@@ -273,6 +298,19 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
273
298
|
attempts.append({"address": address, "tcp": tcp, "state": "transport-failed"})
|
|
274
299
|
continue
|
|
275
300
|
|
|
301
|
+
# Only examine the local agent when transport actually reached the
|
|
302
|
+
# selected target. This remains a read-only observation: do not start
|
|
303
|
+
# an agent, request a passphrase, or alter SSH trust during diagnosis.
|
|
304
|
+
if local_signer["state"] == "not-checked":
|
|
305
|
+
try:
|
|
306
|
+
agent = agent_status(profile)
|
|
307
|
+
local_signer = {
|
|
308
|
+
"state": agent["state"],
|
|
309
|
+
"loaded": agent["loaded"],
|
|
310
|
+
}
|
|
311
|
+
except (DeploymentKitError, OSError, KeyError):
|
|
312
|
+
local_signer = {"state": "not-verified", "loaded": False}
|
|
313
|
+
|
|
276
314
|
auth = probe_authentication_methods(profile, address)
|
|
277
315
|
result = _completed(ssh_args(profile, address, verbose=True))
|
|
278
316
|
text = "\n".join(part for part in (result.stderr, result.stdout) if part)
|
|
@@ -284,6 +322,7 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
284
322
|
"state": "verified",
|
|
285
323
|
"address": address,
|
|
286
324
|
"identityFingerprint": fingerprint,
|
|
325
|
+
"localSigner": local_signer,
|
|
287
326
|
"authentication": auth,
|
|
288
327
|
"offeredConfiguredIdentity": offered,
|
|
289
328
|
"serverAcceptedConfiguredIdentity": accepted,
|
|
@@ -296,6 +335,7 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
296
335
|
"tcp": tcp,
|
|
297
336
|
"authentication": auth,
|
|
298
337
|
"identityFingerprint": fingerprint,
|
|
338
|
+
"localSigner": local_signer,
|
|
299
339
|
"offeredConfiguredIdentity": offered,
|
|
300
340
|
"serverAcceptedConfiguredIdentity": accepted,
|
|
301
341
|
"returnCode": result.returncode,
|
|
@@ -304,6 +344,7 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
304
344
|
auth=auth,
|
|
305
345
|
offered=offered,
|
|
306
346
|
accepted=accepted,
|
|
347
|
+
signer_loaded=local_signer["loaded"],
|
|
307
348
|
),
|
|
308
349
|
}
|
|
309
350
|
)
|
|
@@ -312,6 +353,7 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
312
353
|
for candidate in (
|
|
313
354
|
"host-identity-rejected",
|
|
314
355
|
"local-signing-failed",
|
|
356
|
+
"local-signing-unproven",
|
|
315
357
|
"remote-account-policy-rejected",
|
|
316
358
|
"remote-authentication-rejected",
|
|
317
359
|
"authentication-rejected",
|
|
@@ -325,6 +367,11 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
325
367
|
"transport-failed": "Verify the configured address/port and network path; do not scan or silently switch targets.",
|
|
326
368
|
"host-identity-rejected": "Re-verify host identity independently before any trust rotation.",
|
|
327
369
|
"local-signing-failed": "Repair local key/passphrase/agent use; do not weaken remote authentication policy.",
|
|
370
|
+
"local-signing-unproven": (
|
|
371
|
+
"The server accepted a public key offer, but completed client signing "
|
|
372
|
+
"was not established. Reuse the exact target's supported local SSH agent-session "
|
|
373
|
+
"before investigating remote account policy. Do not weaken host trust."
|
|
374
|
+
),
|
|
328
375
|
"remote-authentication-rejected": (
|
|
329
376
|
"The configured public-key path reached the server but authentication was rejected. "
|
|
330
377
|
"Verify authorized_keys and remote account/source access policy from a trusted console or already-authorized session."
|
|
@@ -342,6 +389,7 @@ def diagnose_access(profile: dict[str, Any]) -> dict[str, Any]:
|
|
|
342
389
|
"state": "failed",
|
|
343
390
|
"failureCategory": category,
|
|
344
391
|
"identityFingerprint": fingerprint,
|
|
392
|
+
"localSigner": local_signer,
|
|
345
393
|
"attempts": attempts,
|
|
346
394
|
"guidance": guidance,
|
|
347
395
|
"mutated": False,
|