@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.
@@ -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
- return {
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(text: str, *, auth: dict[str, Any], offered: bool, accepted: bool) -> str:
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
- return "remote-account-policy-rejected"
217
- if offered or auth.get("publicKeySupported"):
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mriekki/project-forge",
3
- "version": "4.24.1",
3
+ "version": "4.26.0",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {