@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.
- package/assets/components.json +4 -4
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +25 -15
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +69 -0
- package/assets/generated/core/core/skills/deliver/SKILL.md +6 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +21 -0
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +37 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +18 -1
- package/assets/generated/core/tools/setup_maintenance/maintenance.py +162 -6
- 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 +21 -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
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
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": "
|
|
17
|
-
"version": "4.3.
|
|
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": "
|
|
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.
|
|
4
|
+
"version": "4.26.1",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
|
-
"revision": "
|
|
8
|
-
"version": "4.3.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
if
|
|
149
|
-
result["previousCheckUtc"] =
|
|
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
|
-
|
|
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,
|