@mriekki/project-forge 4.25.0 → 4.26.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.25.0",
4
+ "version": "4.26.1",
5
5
  "defaultProfile": "greenfield-project",
6
6
  "profiles": {
7
7
  "greenfield-project": {
@@ -13,15 +13,15 @@
13
13
  },
14
14
  "components": {
15
15
  "core": {
16
- "revision": "5d7c1285793f502d58ad4b6f8f2e51c77453073b",
17
- "version": "4.3.0",
16
+ "revision": "670b84af788323ce361a49677aff772c0d1541c4",
17
+ "version": "4.3.1",
18
18
  "distribution": "greenfield-workspace",
19
19
  "descriptor": "assets/core/distribution.json",
20
20
  "sourceRoot": "assets/generated/core",
21
21
  "canonicalDescriptor": "distributions/greenfield-workspace.json"
22
22
  },
23
23
  "deployment": {
24
- "revision": "97dfcd7152619a434672da5045c0528f41984ced",
24
+ "revision": "e9ca6e12e5b19f691b25ce0aba758a43c3df2913",
25
25
  "distribution": "greenfield-project",
26
26
  "descriptor": "assets/deployment/distribution.json",
27
27
  "sourceRoot": "assets/generated/deployment",
@@ -44,11 +44,13 @@
44
44
  {"source": "src/project_forge_deployment/profiles.py", "destination": "tools/project-forge-deployment/project_forge_deployment/profiles.py"},
45
45
  {"source": "src/project_forge_deployment/project_topology.py", "destination": "tools/project-forge-deployment/project_forge_deployment/project_topology.py"},
46
46
  {"source": "src/project_forge_deployment/proof_receipt.py", "destination": "tools/project-forge-deployment/project_forge_deployment/proof_receipt.py"},
47
+ {"source": "src/project_forge_deployment/proxy_peer.py", "destination": "tools/project-forge-deployment/project_forge_deployment/proxy_peer.py"},
47
48
  {"source": "src/project_forge_deployment/recovery_scope.py", "destination": "tools/project-forge-deployment/project_forge_deployment/recovery_scope.py"},
48
49
  {"source": "src/project_forge_deployment/registry_access.py", "destination": "tools/project-forge-deployment/project_forge_deployment/registry_access.py"},
49
50
  {"source": "src/project_forge_deployment/registry_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/registry_cli.py"},
50
51
  {"source": "src/project_forge_deployment/route_registry.py", "destination": "tools/project-forge-deployment/project_forge_deployment/route_registry.py"},
51
52
  {"source": "src/project_forge_deployment/route_registry_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/route_registry_remote.py"},
53
+ {"source": "src/project_forge_deployment/safe_diagnostics.py", "destination": "tools/project-forge-deployment/project_forge_deployment/safe_diagnostics.py"},
52
54
  {"source": "src/project_forge_deployment/security.py", "destination": "tools/project-forge-deployment/project_forge_deployment/security.py"},
53
55
  {"source": "src/project_forge_deployment/sqlite_adapter.py", "destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter.py"},
54
56
  {"source": "src/project_forge_deployment/sqlite_adapter_remote.py", "destination": "tools/project-forge-deployment/project_forge_deployment/sqlite_adapter_remote.py"},
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@mriekki/project-forge",
4
- "version": "4.25.0",
4
+ "version": "4.26.1",
5
5
  "components": {
6
6
  "core": {
7
- "revision": "5d7c1285793f502d58ad4b6f8f2e51c77453073b",
8
- "version": "4.3.0",
7
+ "revision": "670b84af788323ce361a49677aff772c0d1541c4",
8
+ "version": "4.3.1",
9
9
  "distribution": "greenfield-workspace",
10
10
  "files": [
11
11
  {
@@ -56,7 +56,7 @@
56
56
  {
57
57
  "source": "core/skills/setup/SKILL.md",
58
58
  "destination": ".github/skills/setup/SKILL.md",
59
- "sha256": "fb1f752655c79d7ac68f3424adba2663492a8e9dd0b8d268cabb6dd28880b6fc"
59
+ "sha256": "30a9dbfbd132f7c333ca515736a7957494bbe2fd4b5860dfff90ba3437bc135d"
60
60
  },
61
61
  {
62
62
  "source": "core/skills/build/SKILL.md",
@@ -91,7 +91,7 @@
91
91
  {
92
92
  "source": "core/skills/deliver/SKILL.md",
93
93
  "destination": ".github/skills/deliver/SKILL.md",
94
- "sha256": "f7d4c611358bec5b348df994515d1253b59c72707b3a8f28e846174468c590c2"
94
+ "sha256": "5e4d64d91e429b636e0fda4094a84dd2f0b09aa7c1f281cbba7d630b67820076"
95
95
  },
96
96
  {
97
97
  "source": "core/skills/work-item-status/SKILL.md",
@@ -126,12 +126,12 @@
126
126
  {
127
127
  "source": "core/skills/deployment-readiness/SKILL.md",
128
128
  "destination": ".github/skills/deployment-readiness/SKILL.md",
129
- "sha256": "170e90e1d21050fb1134230f539c41a1ca62193c3d695fda5edee4147d56e929"
129
+ "sha256": "8b83d06056cbc10c0a9df69d1bb76db62753b52f5d4be9858488e400cd4b836d"
130
130
  },
131
131
  {
132
132
  "source": "core/skills/deploy/SKILL.md",
133
133
  "destination": ".github/skills/deploy/SKILL.md",
134
- "sha256": "82c05ae910f65efd170ca6d055cce9e6e5035628eba45bcfc8552a2b1619cce2"
134
+ "sha256": "3ac67207c9cb6870c56313b2c4ce02d15c0756cb092937013f66512d2ec9576a"
135
135
  },
136
136
  {
137
137
  "source": "core/skills/tear-down/SKILL.md",
@@ -196,7 +196,7 @@
196
196
  {
197
197
  "source": "core/process/INTERACTIVE_SESSION_GUIDE.md",
198
198
  "destination": "docs/process/INTERACTIVE_SESSION_GUIDE.md",
199
- "sha256": "71e25cd0603eecac2be67a696386fddf718c6da26e10c0ade74429ff68a3ac30"
199
+ "sha256": "43bb5959fcc71755a76f9c4adcbc24576907db817d9dc0ced4e1607630493843"
200
200
  },
201
201
  {
202
202
  "source": "core/process/COACHING_GUIDE.md",
@@ -376,7 +376,7 @@
376
376
  {
377
377
  "source": "tools/setup_maintenance/maintenance.py",
378
378
  "destination": "tools/project-forge-setup/maintenance.py",
379
- "sha256": "55d22445834108d2fb7f2ac8aa4a5c5b0326c01f67e5f305b8284e4f15a0dcd8"
379
+ "sha256": "15292e1059bc017139a11692346234fe34d98e5745acdfff2197d1232ce57c34"
380
380
  },
381
381
  {
382
382
  "source": "core/process/WORKSTATION_MAINTENANCE.md",
@@ -386,7 +386,7 @@
386
386
  ]
387
387
  },
388
388
  "deployment": {
389
- "revision": "97dfcd7152619a434672da5045c0528f41984ced",
389
+ "revision": "e9ca6e12e5b19f691b25ce0aba758a43c3df2913",
390
390
  "distribution": "greenfield-project",
391
391
  "files": [
392
392
  {
@@ -447,7 +447,7 @@
447
447
  {
448
448
  "source": "src/project_forge_deployment/box_platform.py",
449
449
  "destination": "tools/project-forge-deployment/project_forge_deployment/box_platform.py",
450
- "sha256": "163c022bbd8ecccadebbebdc8cef8e272fc95dd0dc3cdb35d4e6e10013c91040"
450
+ "sha256": "a11603daa47e33570732e349f59720811b5fdc2ec26ea04d08896764e089b682"
451
451
  },
452
452
  {
453
453
  "source": "src/project_forge_deployment/box_platform_assets.py",
@@ -457,7 +457,7 @@
457
457
  {
458
458
  "source": "src/project_forge_deployment/box_platform_remote.py",
459
459
  "destination": "tools/project-forge-deployment/project_forge_deployment/box_platform_remote.py",
460
- "sha256": "65de94ac4b6e980d83ce99ea55b9555aa96959c11808cce8c6ed537b27c15533"
460
+ "sha256": "18a47000d0755c645d5fe8a950f917331abedc332f3e11b9d6638946843a3f0e"
461
461
  },
462
462
  {
463
463
  "source": "src/project_forge_deployment/cloudflare_api.py",
@@ -482,7 +482,7 @@
482
482
  {
483
483
  "source": "src/project_forge_deployment/cli.py",
484
484
  "destination": "tools/project-forge-deployment/project_forge_deployment/cli.py",
485
- "sha256": "6b4bb3a23b3df8387f13f544ce02e1d2bee863777de4965044435769c24ab98b"
485
+ "sha256": "5f22a0fed79fe7dcb12b94f670bda04ea503f5254ccacfd841f03dfedd2ceeb5"
486
486
  },
487
487
  {
488
488
  "source": "src/project_forge_deployment/doctor.py",
@@ -512,7 +512,7 @@
512
512
  {
513
513
  "source": "src/project_forge_deployment/environment_setup.py",
514
514
  "destination": "tools/project-forge-deployment/project_forge_deployment/environment_setup.py",
515
- "sha256": "47ce6af4ab3b03c441725ec8cc1b356212ef7a1706635fe191affd959b09a5f5"
515
+ "sha256": "934b6d8f2309128da07ced858ea4b9c56b04c5ff67b6efc5c52d81a10a1a696b"
516
516
  },
517
517
  {
518
518
  "source": "src/project_forge_deployment/errors.py",
@@ -594,6 +594,11 @@
594
594
  "destination": "tools/project-forge-deployment/project_forge_deployment/proof_receipt.py",
595
595
  "sha256": "9b4bbb559747fc4ee3ba984a050fc6425e532527ae82f9897c73e04d8d0291b6"
596
596
  },
597
+ {
598
+ "source": "src/project_forge_deployment/proxy_peer.py",
599
+ "destination": "tools/project-forge-deployment/project_forge_deployment/proxy_peer.py",
600
+ "sha256": "ae6c8cb26b83ed07765fa4740ce5c68f52cafd2209b4f055887a4457cfb41c03"
601
+ },
597
602
  {
598
603
  "source": "src/project_forge_deployment/recovery_scope.py",
599
604
  "destination": "tools/project-forge-deployment/project_forge_deployment/recovery_scope.py",
@@ -619,6 +624,11 @@
619
624
  "destination": "tools/project-forge-deployment/project_forge_deployment/route_registry_remote.py",
620
625
  "sha256": "c8f397a09ff9efc904230eeec68aa85bab9c21529e44dd925e5db0f02f406b6f"
621
626
  },
627
+ {
628
+ "source": "src/project_forge_deployment/safe_diagnostics.py",
629
+ "destination": "tools/project-forge-deployment/project_forge_deployment/safe_diagnostics.py",
630
+ "sha256": "58372e220e25fc468be490c8a788e347a1026ccbdff899217cdbe8aecb7c00ce"
631
+ },
622
632
  {
623
633
  "source": "src/project_forge_deployment/security.py",
624
634
  "destination": "tools/project-forge-deployment/project_forge_deployment/security.py",
@@ -647,7 +657,7 @@
647
657
  {
648
658
  "source": "src/project_forge_deployment/ssh.py",
649
659
  "destination": "tools/project-forge-deployment/project_forge_deployment/ssh.py",
650
- "sha256": "88d59b62f63dc6b7206b6074dd109af4ea7c8f022f506e67f7204f1f06240d26"
660
+ "sha256": "b45ace8c35e622cb336ade9aeafa7405f96660e1e4faed0303faa4edb24b95c5"
651
661
  },
652
662
  {
653
663
  "source": "src/project_forge_deployment/teardown.py",
@@ -140,3 +140,72 @@ Compound action skills may also define their own bounded authorization. In parti
140
140
  If a tool/channel interruption leaves a compound workflow partially complete, a later invocation should inspect current state and resume from the furthest safe point rather than assuming nothing happened or requiring the same routine authorization again.
141
141
 
142
142
  `/wait-what` is different. It is a one-turn, read-only communication repair for the immediately preceding explanation. It re-establishes context and clarity, then returns control to the current workflow without silently continuing a pending action or approval gate.
143
+
144
+ ## Operational continuity and bounded retries (PF-03/PF-04)
145
+
146
+ Use these rules within existing Project Forge skills and repository-defined
147
+ operations. This is **not** a new approval store, model selection policy, or
148
+ background execution system.
149
+
150
+ ### Scope a working authorization before a consequential action
151
+
152
+ Before a multi-stage or unattended continuation, state a short **reviewed-stage
153
+ envelope** in the already-existing work item/review context:
154
+
155
+ - Exact Project, named logical environment/target, host identity where relevant,
156
+ and intended operation(s).
157
+ - Immutable artifact, plan identity and reviewed evidence, including freshness
158
+ and expiry when the installed capability exposes them.
159
+ - Allowed mechanical continuation and **separate** Production runtime, public
160
+ route, trust/credential, destructive data, merge, and release boundaries.
161
+ - Remaining secret-input or human decision points and the precise stop conditions.
162
+
163
+ One currently approved, unchanged reviewed stage authorizes its supported
164
+ routine mechanics; never request a second identical shell phrase or chat
165
+ permission when the installed deterministic command supports scoped execution.
166
+ However, authorization is **not** a standing token: changed target, plan, peer,
167
+ data/backup risk, credential, identity, exposure, trust or other consequence
168
+ reopens the relevant gate. Never infer indefinite authority from elapsed time
169
+ or a prior session. Explain any interactive passphrase/provider action **before**
170
+ the developer leaves an unattended workflow; do not promise unattended
171
+ completion if required local input or a human approval remains.
172
+
173
+ ### Read safe results without losing the failed phase
174
+
175
+ When the installed Deployment Kit supplies an additive safe
176
+ `diagnosticSummary`, use it only if the **observed installed** schema and
177
+ capability version match the documented contract (currently schema 1,
178
+ `remote-environment-setup` capability 1). Preserve phase, fixed reasonCode,
179
+ proved/failed/blocked/notChecked dimensions, observedAt, mutation, consequence
180
+ and any safe evidence reference. An unknown capability, reason, malformed
181
+ summary or mixed stderr becomes **unclassified**, never green/success.
182
+ Report unsupported or older installed tooling honestly; do not reconstruct the
183
+ same field by parsing private exceptions or create a new runtime prerequisite
184
+ during Bootstrap. A concise summary is not a complete reviewed mutation plan.
185
+
186
+ ### Avoid no-information attempts and context inflation
187
+
188
+ If the **same** fixed reason code and non-secret exact target/phase/context have
189
+ failed twice with unchanged inputs **and no new evidence**, change the method
190
+ (e.g. inspect a different bounded proof) or stop with the precise missing fact.
191
+ Do not perform a third identical attempt, rerun an unchanged reviewer objective,
192
+ replay a failed login, or repeatedly ask for the same fact. A newly loaded SSH
193
+ agent, changed plan, confirmed host identity, updated installed helper or other
194
+ material new evidence resets the same-phase retry comparison.
195
+
196
+ Start a continuation with the smallest authoritative repository/state
197
+ checkpoint: current work-item state, exact commit/PR/CI, package/component
198
+ provenance, observed capability and proof freshness, unresolved decision, and
199
+ next safe action. Follow links to deeper current evidence only as needed.
200
+ Do not reload a whole incident transcript, broad skill catalog or all historical
201
+ retries just because context is available. Favor one bounded wait/read of a
202
+ known CI run ID over repeated short polling and large tool output. Use only
203
+ relevant skill and artifact slices; keep full evidence available separately.
204
+
205
+ Measure improvements with one matched, bounded replay holding the same fault,
206
+ safety obligations and environmental assumptions. Record model/tool rounds,
207
+ operator prompts, elapsed/waiting time and cache-adjusted token volume:
208
+ input tokens minus cache reads plus output tokens, respecting each counter's
209
+ semantics. Candidate >=50% reductions are **targets**, not guaranteed savings.
210
+ Never hide failed checks, truncate material mutation evidence, skip security
211
+ gates, or change model tier automatically to make metrics look better.
@@ -185,6 +185,12 @@ Delivery state preserved. Re-run /deliver to resume.
185
185
  ```
186
186
 
187
187
  Do not insert a new write-authorization prompt merely because CI is pending.
188
+ Use the exact known workflow/run ID and one bounded wait/recheck when possible
189
+ rather than repeated short polling, reloading the same CI output, or relaunching
190
+ a Reviewer on an unchanged objective. Treat two unchanged, no-information
191
+ attempts at one phase as a stop/change-method condition. Preserve the PR/commit
192
+ and CI status as a concise authoritative resumption checkpoint. See
193
+ `core/process/INTERACTIVE_SESSION_GUIDE.md` for freshness and context rules.
188
194
 
189
195
  ### Failed
190
196
 
@@ -226,8 +226,29 @@ Authorization applies only to the exact environment, resolved classification, pl
226
226
 
227
227
  **Avoid duplicate terminal approval:** when the Builder has explicitly approved this exact current stage in VS Code Copilot Chat, the assistant may pass the installed Deployment Kit's supported `--yes` flag for **that stage only** instead of making the Builder type the displayed phrase again in Bash/PowerShell. `--yes` is an execution flag, not approval evidence; never use it because the human previously approved a different plan, artifact, target, route, session, or stage. If the installed command lacks the flag, preserve its normal gate. Destructive resets/restores and SSH trust/credential changes are independent consequences and are not authorized by a deployment approval.
228
228
 
229
+ Before unattended rollout, identify a concise reviewed-stage envelope:
230
+ exact target, immutable artifact, permitted operations, current plan/proof
231
+ identity and freshness, distinct runtime/route/data/trust boundaries, and any
232
+ remaining local secret or human approval stop. Do not promise to finish
233
+ unattended if those stops remain. Repeat safe mechanics within unchanged
234
+ authority; reopen only for changed evidence or a genuinely new consequence.
235
+ After two unchanged failures with no new evidence, change the method or
236
+ stop instead of repeatedly recreating the same plan. Apply the bounded
237
+ continuation policy in `core/process/INTERACTIVE_SESSION_GUIDE.md`.
238
+
229
239
  A runtime authorization never authorizes the route stage. A route authorization never retroactively authorizes a changed runtime artifact. If the rollout contains both stages, preserve the separate gates even though the Builder reviewed one coherent outcome.
230
240
 
241
+ For authenticated public ingress relying on a pinned exact Caddy peer,
242
+ preserve current read-only managed peer evidence (when supported) as a
243
+ separate plan dimension from DNS, route and image readiness. If observed
244
+ Caddy container/network identity or peer IP changed from the reviewed
245
+ application binding, **do not silently update the trusted proxy**, change
246
+ the release image, or continue the public-authentication rollout. Prepare a
247
+ separate app-owned reviewed convergence/health/rollback stage.
248
+ Proxy observation alone neither authorizes runtime writes nor proves live end-to-end
249
+ OIDC/public-origin behavior. Follow the PF-06 producer's immutable identity
250
+ review boundary, or report the installed capability as unavailable.
251
+
231
252
  ## 9. Preview and execute only the approved contract
232
253
 
233
254
  Before mutation, show concise non-secret evidence such as exact environment, purpose, placement, target/provider when applicable, proof freshness, immutable application artifact, persistence/migration, exposure/route, application isolation state/fingerprint when exposed, shared failure-domain note when relevant, planned stages, endpoint state at each stage, and the exact required authorization phrase for the next consequence only.
@@ -202,6 +202,43 @@ Invoking `/deployment-readiness <environment>` or directly asking to check readi
202
202
 
203
203
  When the installed Deployment Kit supports `target agent-session`, run supported remote checks via `target agent-session <target> -- <normal Deployment Kit command>` rather than copying `SSH_AUTH_SOCK` paths into each command. For existing Project-owned Python readiness scripts that use the same verified target, use the installed `target agent-run <target> -- <exact reviewed local executable and argv>` to inherit this SSH context without shell evaluation; do not invent script arguments or bypass their gates. This prepares only local SSH authentication (WSL/Linux stable private agent; macOS existing SSH agent or private fallback; native Windows OpenSSH agent service). It must not start or configure a Windows system service, enroll trust, skip an SSH passphrase, or authorize any remote write. If the key needs interactive local input, hand the exact non-secret command to the developer-owned terminal once and resume validation afterward. For older installed kits, use their supported SSH workflow and report the update opportunity rather than fabricating a command.
204
204
 
205
+ ### Safe diagnostic continuation and no-progress bound
206
+
207
+ When the installed Deployment Kit exposes a recognized v1 `diagnosticSummary`,
208
+ read the fixed reasonCode, failure phase, independently proved/blocked/notChecked
209
+ dimensions, observedAt and mutation status **without** losing unrelated good
210
+ readiness evidence. Continue using the existing exact-target read-only
211
+ authorization. If schema/capability versions are unknown or no usable reason
212
+ is present, state that diagnosis is unclassified, not successful.
213
+ For `local-signing-unproven`, use the supported configured
214
+ `target agent-session` before investigating remote policy; a server's
215
+ preliminary key acceptance does not prove that the client signed. Older
216
+ installed kits may lack these fields: do not invent them.
217
+
218
+ Use the bounded retry and evidence-freshness policy in
219
+ `core/process/INTERACTIVE_SESSION_GUIDE.md`: after two unchanged,
220
+ no-information attempts at the same phase, change the diagnostic method or
221
+ report the exact evidence gap. Do not replay CI/reviewers or request the
222
+ same unchanged read authorization.
223
+
224
+ ### Exact authenticated-origin peer convergence (PF-06)
225
+
226
+ For an application whose approved public-origin policy pins the managed Caddy
227
+ peer, use the **installed** Deployment Kit's read-only
228
+ `platform peer-inspect <target> --configured-peer <ip> --previous-container-id <full-id> --previous-network-id <full-id> --json`
229
+ when supported and the application's previously reviewed identity evidence
230
+ exists. Preserve the exact named target and classify a mismatch as a
231
+ **review-required stop**. A missing previous full identity is
232
+ `needs-reviewed-identity-baseline`, not established trust. Do not resolve a
233
+ missing baseline by assuming current Docker identity is implicitly approved.
234
+
235
+ This observation is not a token/certificate proving Caddy authenticity, not
236
+ permission to rewrite the application-configured peer, and not proof of
237
+ Cloudflare forwarding or of application middleware enforcement. Never trust
238
+ a Docker subnet or allow all LAN peers as a workaround. If the installed Kit
239
+ does not support this inspection, report the capability gap rather than
240
+ inventing a new script or changing Production.
241
+
205
242
  #### Real application isolation preflight
206
243
 
207
244
  For a supported remote application deployment, prefer the repository-distributed launcher:
@@ -403,7 +403,24 @@ For fingerprint input, show the expected public form such as `SHA256:<fingerprin
403
403
 
404
404
  If a local key/agent operation requires a passphrase or another terminal-owned secret prompt, follow the interactive-terminal handoff contract: collect any distinct bounded authorization once, then immediately provide the exact standalone copyable command, correct machine/working directory, expected local prompt, and the non-secret completion evidence to report. Do not ask for the passphrase through chat or ask a second ceremonial question merely to repeat the same authorized command.
405
405
 
406
- If client-side evidence shows that the configured key reached the server but access is still rejected, stop reinstalling the same key. Route to a trusted target console or already-authorized administrative session to inspect effective account/source policy. Do not guess that `AllowUsers`, `AllowGroups`, PAM, or another server rule is the exact cause until server-side evidence establishes it.
406
+ **SSH signing proof matters:** a preliminary `Server accepts key` message
407
+ means the public key was eligible, **not** that local signing finished or the
408
+ remote account rejected an authenticated user. If the selected agent is absent
409
+ or signing is unproven (the fixed diagnostic code is `local-signing-unproven`),
410
+ try the already-supported exact-target
411
+ `target agent-session` in a trusted local terminal (only with the required
412
+ bounded local authority), then retry the unchanged read-only check once.
413
+ If client signature evidence exists but SSH is still rejected, report remote
414
+ authentication failure without claiming a particular account policy. Inspect
415
+ server account/source policy from a trusted console only when the evidence
416
+ actually justifies that branch. Do not repeatedly reinstall a key, weaken
417
+ trust, turn on password authentication or invent a server configuration fix.
418
+
419
+ Use recognized installed v1 `diagnosticSummary` fields and the bounded
420
+ continuation/no-progress policy in `core/process/INTERACTIVE_SESSION_GUIDE.md`
421
+ when available; older Kit output remains an explicitly limited capability.
422
+ Keep independent readiness evidence and do not reflect arbitrary exceptions
423
+ or secret-bearing stderr to Chat.
407
424
 
408
425
  ### One bounded trusted-console diagnostic bundle
409
426
 
@@ -2,13 +2,18 @@
2
2
  """Project Forge catalog-bound workstation updates. Status/preview never mutate."""
3
3
  from __future__ import annotations
4
4
  import argparse
5
+ from contextlib import ExitStack
5
6
  import datetime as dt
6
7
  import hashlib
7
8
  import json
9
+ import os
8
10
  import re
11
+ import secrets
9
12
  import shutil
13
+ import stat
10
14
  import subprocess
11
15
  import sys
16
+ import tempfile
12
17
  from pathlib import Path
13
18
 
14
19
  TOOL_ID = re.compile(r"^[a-z0-9][a-z0-9-]*$")
@@ -125,6 +130,160 @@ def make_plan(root: Path, selected: list[str]) -> dict:
125
130
  return {"plan": plan, "sha256": digest}
126
131
 
127
132
 
133
+ def _marker_payload() -> str:
134
+ return json.dumps({"checkedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat()}) + "\n"
135
+
136
+
137
+ def _marker_read_value(handle) -> str | None:
138
+ value = json.load(handle)
139
+ if not isinstance(value, dict):
140
+ raise ValueError("expected object in setup maintenance marker")
141
+ return value.get("checkedAtUtc")
142
+
143
+
144
+ def _posix_marker_check(root: Path, record: bool) -> str | None:
145
+ """Use directory handles and O_NOFOLLOW throughout: no path-based write races."""
146
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
147
+ with ExitStack() as closing:
148
+ parent = os.open(root, flags)
149
+ closing.callback(os.close, parent)
150
+ for name in (".project-forge", "setup-maintenance"):
151
+ try:
152
+ child = os.open(name, flags, dir_fd=parent)
153
+ except FileNotFoundError:
154
+ if not record:
155
+ return None
156
+ try:
157
+ os.mkdir(name, 0o700, dir_fd=parent)
158
+ except FileExistsError:
159
+ pass
160
+ try:
161
+ child = os.open(name, flags, dir_fd=parent)
162
+ except OSError as error:
163
+ raise ValueError(
164
+ f"unsafe setup maintenance marker directory: {name} (symlink or non-directory)"
165
+ ) from error
166
+ except OSError as error:
167
+ raise ValueError(
168
+ f"unsafe setup maintenance marker directory: {name} (symlink or non-directory)"
169
+ ) from error
170
+ closing.callback(os.close, child)
171
+ parent = child
172
+
173
+ name = "last-check.json"
174
+ try:
175
+ info = os.stat(name, dir_fd=parent, follow_symlinks=False)
176
+ except FileNotFoundError:
177
+ info = None
178
+ if info is not None and not stat.S_ISREG(info.st_mode):
179
+ raise ValueError("unsafe setup maintenance marker file: symlink or non-regular file")
180
+
181
+ previous = None
182
+ if info is not None:
183
+ # Re-open through the pinned directory handle and check the final
184
+ # inode too, so a swapped symlink or special file cannot be read.
185
+ with os.fdopen(
186
+ os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent),
187
+ "r", encoding="utf-8"
188
+ ) as handle:
189
+ if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
190
+ raise ValueError("unsafe setup maintenance marker file: non-regular file")
191
+ previous = _marker_read_value(handle)
192
+
193
+ if record:
194
+ # Atomic replacement instead of truncating the existing inode:
195
+ # neither a symlink nor a hard link can redirect the marker write.
196
+ temporary = f".last-check-{secrets.token_hex(12)}.tmp"
197
+ created = False
198
+ try:
199
+ fd = os.open(
200
+ temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
201
+ 0o600, dir_fd=parent
202
+ )
203
+ created = True
204
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
205
+ handle.write(_marker_payload())
206
+ handle.flush()
207
+ os.fsync(handle.fileno())
208
+ os.replace(temporary, name, src_dir_fd=parent, dst_dir_fd=parent)
209
+ created = False
210
+ finally:
211
+ if created:
212
+ os.unlink(temporary, dir_fd=parent)
213
+ return previous
214
+
215
+
216
+ def _windows_marker_component(path: Path, *, directory: bool) -> os.stat_result | None:
217
+ """Refuse symlinks, junctions and other Windows reparse points."""
218
+ try:
219
+ info = os.lstat(path)
220
+ except FileNotFoundError:
221
+ return None
222
+ if (
223
+ stat.S_ISLNK(info.st_mode)
224
+ or getattr(info, "st_file_attributes", 0)
225
+ & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
226
+ or (directory and not stat.S_ISDIR(info.st_mode))
227
+ or (not directory and not stat.S_ISREG(info.st_mode))
228
+ ):
229
+ raise ValueError(f"unsafe setup maintenance marker path: symlink, reparse point or unexpected type at {path}")
230
+ return info
231
+
232
+
233
+ def _windows_marker_check(root: Path, record: bool) -> str | None:
234
+ """Conservative native Windows checks with an atomic final-file replacement.
235
+
236
+ Native Windows Python has no POSIX dir_fd/O_NOFOLLOW operations. Check each
237
+ component for reparse points, retain its identity, and recheck before the
238
+ final atomic replace. Never use write_text() on the destination.
239
+ """
240
+ parent = root
241
+ identities = []
242
+ for name in (".project-forge", "setup-maintenance"):
243
+ parent = parent / name
244
+ info = _windows_marker_component(parent, directory=True)
245
+ if info is None:
246
+ if not record:
247
+ return None
248
+ try:
249
+ parent.mkdir(mode=0o700)
250
+ except FileExistsError:
251
+ pass
252
+ info = _windows_marker_component(parent, directory=True)
253
+ if info is None:
254
+ raise ValueError(f"unable to establish safe setup maintenance directory: {parent}")
255
+ identities.append((parent, info.st_dev, info.st_ino))
256
+
257
+ marker = parent / "last-check.json"
258
+ existing = _windows_marker_component(marker, directory=False)
259
+ previous = None
260
+ if existing is not None:
261
+ with marker.open("r", encoding="utf-8") as handle:
262
+ previous = _marker_read_value(handle)
263
+ if record:
264
+ fd, tmp = tempfile.mkstemp(prefix=".last-check-", suffix=".tmp", dir=parent)
265
+ try:
266
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
267
+ handle.write(_marker_payload())
268
+ handle.flush()
269
+ os.fsync(handle.fileno())
270
+ for directory, dev, ino in identities:
271
+ current = _windows_marker_component(directory, directory=True)
272
+ if current is None or (current.st_dev, current.st_ino) != (dev, ino):
273
+ raise ValueError(f"unsafe setup maintenance directory changed before marker replacement: {directory}")
274
+ _windows_marker_component(marker, directory=False)
275
+ os.replace(tmp, marker)
276
+ finally:
277
+ if os.path.lexists(tmp):
278
+ os.unlink(tmp)
279
+ return previous
280
+
281
+
282
+ def marker_check(root: Path, *, record: bool) -> str | None:
283
+ """Read and optionally store a check timestamp only in a safe checkout path."""
284
+ return _windows_marker_check(root, record) if os.name == "nt" else _posix_marker_check(root, record)
285
+
286
+
128
287
  def main() -> int:
129
288
  parser = argparse.ArgumentParser()
130
289
  parser.add_argument("action", choices=["status", "update"])
@@ -144,12 +303,9 @@ def main() -> int:
144
303
  try:
145
304
  if args.action == "status":
146
305
  result = {"platform": platform(), "tools": inventory(root)}
147
- marker = root / ".project-forge/setup-maintenance/last-check.json"
148
- if marker.is_file():
149
- result["previousCheckUtc"] = load(marker).get("checkedAtUtc")
150
- if args.record_check:
151
- marker.parent.mkdir(parents=True, exist_ok=True)
152
- marker.write_text(json.dumps({"checkedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat()}) + "\n", encoding="utf-8")
306
+ previous = marker_check(root, record=args.record_check)
307
+ if previous is not None:
308
+ result["previousCheckUtc"] = previous
153
309
  print(json.dumps(result, indent=2))
154
310
  return 0
155
311
  result = make_plan(root, args.tool)
@@ -101,7 +101,7 @@ def _parse_status(output: str) -> dict[str, Any]:
101
101
 
102
102
 
103
103
  def _run_remote(profile: dict[str, Any], address: str, action: str, *, sudo: bool) -> str:
104
- if action not in {"status", "install", "upgrade"}:
104
+ if action not in {"status", "install", "upgrade", "proxy-peer"}:
105
105
  raise DeploymentKitError(f"Unsupported host-platform action: {action}")
106
106
  remote_command = "bash -s -- " + shlex.quote(action)
107
107
  if sudo:
@@ -130,6 +130,36 @@ def platform_status(name: str) -> dict[str, Any]:
130
130
  return status
131
131
 
132
132
 
133
+ def platform_proxy_peer(
134
+ name: str,
135
+ *,
136
+ configured_peer: str | None = None,
137
+ previous_container_id: str | None = None,
138
+ previous_network_id: str | None = None,
139
+ ) -> dict[str, Any]:
140
+ """Read current Caddy peer and produce a no-write app convergence decision.
141
+
142
+ This is NOT authority to update an application's trusted peer. That
143
+ requires its own verified plan, meaningful review and runtime write gate.
144
+ """
145
+ from .proxy_peer import assess_configured_peer, parse_peer_observation
146
+
147
+ profile = load_profile(name)
148
+ evidence = verify_access(profile)
149
+ observed = parse_peer_observation(
150
+ _run_remote(profile, evidence["address"], "proxy-peer", sudo=True)
151
+ )
152
+ plan = assess_configured_peer(
153
+ observed,
154
+ configured_peer,
155
+ previous_container_id=previous_container_id,
156
+ previous_network_id=previous_network_id,
157
+ )
158
+ plan["targetName"] = name
159
+ plan["mutated"] = False
160
+ return plan
161
+
162
+
133
163
  def platform_doctor(name: str) -> dict[str, Any]:
134
164
  status = platform_status(name)
135
165
  next_actions = {
@@ -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
@@ -340,6 +341,15 @@ def build_parser() -> argparse.ArgumentParser:
340
341
  )
341
342
  platform_doctor_parser.add_argument("name")
342
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")
343
353
  for command in ("install", "upgrade"):
344
354
  change = platform_sub.add_parser(
345
355
  command,
@@ -486,6 +496,17 @@ def main(argv: list[str] | None = None) -> int:
486
496
  if args.group == "platform" and args.command == "doctor":
487
497
  _print(platform_doctor(args.name), as_json=args.json)
488
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
489
510
  if args.group == "platform" and args.command in {"install", "upgrade"}:
490
511
  return _platform_change(args)
491
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.25.0",
3
+ "version": "4.26.1",
4
4
  "description": "Public consumer distribution and updater for Project Forge engineering assets.",
5
5
  "type": "module",
6
6
  "bin": {