@mriekki/project-forge 4.5.0 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/bootstrap-migrations/managed-baseline-verifier/check-managed-baselines.py +180 -0
- package/assets/bootstrap-migrations/managed-baseline-verifier/manifest.json +17 -0
- package/assets/components.json +3 -3
- package/assets/deployment/distribution.json +2 -0
- package/assets/generated/ASSEMBLY.json +18 -8
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +4 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +11 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +94 -5
- package/assets/generated/deployment/src/project_forge_deployment/access_cli.py +65 -0
- package/assets/generated/deployment/src/project_forge_deployment/access_diagnosis.py +116 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +4 -0
- package/assets/generated/deployment/src/project_forge_deployment/security.py +44 -15
- package/package.json +1 -1
- package/src/bootstrap-migrations.mjs +160 -0
- package/src/cli.mjs +20 -8
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
|
|
9
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_json(name: str) -> dict:
|
|
13
|
+
path = ROOT / name
|
|
14
|
+
try:
|
|
15
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
16
|
+
except Exception as exc:
|
|
17
|
+
raise RuntimeError(f"Unable to read {name}: {exc}") from exc
|
|
18
|
+
if not isinstance(value, dict):
|
|
19
|
+
raise RuntimeError(f"{name} must contain a JSON object")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_relative(value: object, label: str) -> str:
|
|
24
|
+
if not isinstance(value, str) or not value:
|
|
25
|
+
raise RuntimeError(f"{label} must be a non-empty relative path")
|
|
26
|
+
normalized = value.replace("\\", "/")
|
|
27
|
+
pure = PurePosixPath(normalized)
|
|
28
|
+
if pure.is_absolute() or not pure.parts or any(part in {"", ".", ".."} for part in pure.parts):
|
|
29
|
+
raise RuntimeError(f"Unsafe {label}: {value}")
|
|
30
|
+
return normalized
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256(path: Path) -> str:
|
|
34
|
+
digest = hashlib.sha256()
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
37
|
+
digest.update(chunk)
|
|
38
|
+
return digest.hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def project_component_metadata(component: str, metadata: dict, schema_version: int) -> dict:
|
|
42
|
+
if schema_version == 1:
|
|
43
|
+
result = {
|
|
44
|
+
"revision": metadata.get("commit"),
|
|
45
|
+
"distribution": metadata.get("distribution"),
|
|
46
|
+
}
|
|
47
|
+
if component == "core":
|
|
48
|
+
result["version"] = metadata.get("version")
|
|
49
|
+
return result
|
|
50
|
+
if schema_version == 2:
|
|
51
|
+
result = {
|
|
52
|
+
"revision": metadata.get("revision"),
|
|
53
|
+
"distribution": metadata.get("distribution"),
|
|
54
|
+
}
|
|
55
|
+
if component == "core":
|
|
56
|
+
result["version"] = metadata.get("version")
|
|
57
|
+
return result
|
|
58
|
+
raise RuntimeError("Unsupported PROJECT_FORGE_BASELINE.json schemaVersion")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def component_baseline_metadata(component: str, baseline: dict, filename: str) -> dict:
|
|
62
|
+
schema_version = baseline.get("schemaVersion")
|
|
63
|
+
if schema_version == 1:
|
|
64
|
+
if component == "core":
|
|
65
|
+
return {
|
|
66
|
+
"revision": baseline.get("coreCommit"),
|
|
67
|
+
"version": baseline.get("coreVersion"),
|
|
68
|
+
"distribution": baseline.get("distribution"),
|
|
69
|
+
}
|
|
70
|
+
if component == "deployment":
|
|
71
|
+
return {
|
|
72
|
+
"revision": baseline.get("deploymentKitCommit"),
|
|
73
|
+
"distribution": baseline.get("distribution"),
|
|
74
|
+
}
|
|
75
|
+
elif schema_version == 2:
|
|
76
|
+
if baseline.get("component") != component:
|
|
77
|
+
raise RuntimeError(f"{filename} component does not match PROJECT_FORGE_BASELINE.json")
|
|
78
|
+
result = {
|
|
79
|
+
"revision": baseline.get("revision"),
|
|
80
|
+
"distribution": baseline.get("distribution"),
|
|
81
|
+
}
|
|
82
|
+
if component == "core":
|
|
83
|
+
result["version"] = baseline.get("version")
|
|
84
|
+
return result
|
|
85
|
+
else:
|
|
86
|
+
raise RuntimeError(f"Unsupported {filename} schemaVersion")
|
|
87
|
+
raise RuntimeError(f"Unsupported Project Forge component in generated baseline: {component}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_component(
|
|
91
|
+
component: str,
|
|
92
|
+
project_forge_component: dict,
|
|
93
|
+
filename: str,
|
|
94
|
+
project_schema_version: int,
|
|
95
|
+
) -> int:
|
|
96
|
+
baseline = read_json(filename)
|
|
97
|
+
if baseline.get("schemaVersion") != project_schema_version:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"{filename} schemaVersion does not match PROJECT_FORGE_BASELINE.json"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expected = project_component_metadata(component, project_forge_component, project_schema_version)
|
|
103
|
+
actual = component_baseline_metadata(component, baseline, filename)
|
|
104
|
+
for key, expected_value in expected.items():
|
|
105
|
+
if expected_value is not None and actual.get(key) != expected_value:
|
|
106
|
+
raise RuntimeError(
|
|
107
|
+
f"{filename} {key} does not match PROJECT_FORGE_BASELINE.json"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
managed = baseline.get("managedPaths")
|
|
111
|
+
if not isinstance(managed, list) or not managed:
|
|
112
|
+
raise RuntimeError(f"{filename} managedPaths is missing or empty")
|
|
113
|
+
|
|
114
|
+
count = 0
|
|
115
|
+
seen: set[str] = set()
|
|
116
|
+
for item in managed:
|
|
117
|
+
if not isinstance(item, dict):
|
|
118
|
+
raise RuntimeError(f"Invalid managedPaths entry in {filename}")
|
|
119
|
+
relative = safe_relative(item.get("path"), f"{filename} managed path")
|
|
120
|
+
expected_hash = item.get("sha256")
|
|
121
|
+
if not isinstance(expected_hash, str) or len(expected_hash) != 64:
|
|
122
|
+
raise RuntimeError(f"Invalid managed hash in {filename}: {relative}")
|
|
123
|
+
if relative in seen:
|
|
124
|
+
raise RuntimeError(f"Duplicate managed path in {filename}: {relative}")
|
|
125
|
+
seen.add(relative)
|
|
126
|
+
path = ROOT.joinpath(*PurePosixPath(relative).parts)
|
|
127
|
+
if path.is_symlink() or not path.is_file():
|
|
128
|
+
raise RuntimeError(f"Managed file missing or unsafe: {relative}")
|
|
129
|
+
actual_hash = sha256(path)
|
|
130
|
+
if actual_hash != expected_hash:
|
|
131
|
+
raise RuntimeError(f"Managed file drift: {relative}")
|
|
132
|
+
count += 1
|
|
133
|
+
return count
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main() -> int:
|
|
137
|
+
try:
|
|
138
|
+
manifest = read_json("PROJECT_BOOTSTRAP.json")
|
|
139
|
+
approved = (manifest.get("generation") or {}).get("project-forge") or {}
|
|
140
|
+
project_forge = read_json("PROJECT_FORGE_BASELINE.json")
|
|
141
|
+
project_schema_version = project_forge.get("schemaVersion")
|
|
142
|
+
if project_schema_version not in {1, 2}:
|
|
143
|
+
raise RuntimeError("Unsupported PROJECT_FORGE_BASELINE.json schemaVersion")
|
|
144
|
+
for key in ["package", "version", "profile"]:
|
|
145
|
+
if project_forge.get(key) != approved.get(key):
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"PROJECT_FORGE_BASELINE.json {key} does not match PROJECT_BOOTSTRAP.json"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
components = project_forge.get("components")
|
|
151
|
+
if not isinstance(components, dict) or not components:
|
|
152
|
+
raise RuntimeError("PROJECT_FORGE_BASELINE.json components is missing or empty")
|
|
153
|
+
|
|
154
|
+
total = 0
|
|
155
|
+
for component, metadata in sorted(components.items()):
|
|
156
|
+
if component == "core":
|
|
157
|
+
filename = "CORE_BASELINE.json"
|
|
158
|
+
elif component == "deployment":
|
|
159
|
+
filename = "DEPLOYMENT_BASELINE.json"
|
|
160
|
+
else:
|
|
161
|
+
raise RuntimeError(f"Unsupported Project Forge component: {component}")
|
|
162
|
+
if not isinstance(metadata, dict):
|
|
163
|
+
raise RuntimeError(f"Invalid Project Forge component metadata: {component}")
|
|
164
|
+
total += validate_component(
|
|
165
|
+
component,
|
|
166
|
+
metadata,
|
|
167
|
+
filename,
|
|
168
|
+
project_schema_version,
|
|
169
|
+
)
|
|
170
|
+
print(f"PASS {filename}")
|
|
171
|
+
|
|
172
|
+
print(f"PASS Project Forge managed baselines ({total} files)")
|
|
173
|
+
return 0
|
|
174
|
+
except RuntimeError as exc:
|
|
175
|
+
print(f"FAIL Project Forge managed baselines: {exc}", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "bootstrap-managed-baseline-verifier-schema2",
|
|
4
|
+
"owner": "bootstrap",
|
|
5
|
+
"destination": "tools/project-forge-package/check-managed-baselines.py",
|
|
6
|
+
"asset": "assets/bootstrap-migrations/managed-baseline-verifier/check-managed-baselines.py",
|
|
7
|
+
"source": {
|
|
8
|
+
"component": "bootstrap",
|
|
9
|
+
"revision": "b5a83729d211e0492be5368ff7f1e59b63986419",
|
|
10
|
+
"artifact": "migration-assets/package-workspace/check-managed-baselines.py"
|
|
11
|
+
},
|
|
12
|
+
"targetGitBlob": "26496854ed5d6ad47c7b6136078b2114473ac773",
|
|
13
|
+
"supportedPredecessorGitBlobs": [
|
|
14
|
+
"f7ccfc0e8efaac4d6aed6ed5d8e2130d4fb3dc9d"
|
|
15
|
+
],
|
|
16
|
+
"supportedProjectBaselineSchemas": [1, 2]
|
|
17
|
+
}
|
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.7.0",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
|
-
"revision": "
|
|
16
|
+
"revision": "737dab0636252410c022be057d25c36c1dd5f116",
|
|
17
17
|
"version": "4.0.0",
|
|
18
18
|
"distribution": "greenfield-workspace",
|
|
19
19
|
"descriptor": "assets/core/distribution.json",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"canonicalDescriptor": "distributions/greenfield-workspace.json"
|
|
22
22
|
},
|
|
23
23
|
"deployment": {
|
|
24
|
-
"revision": "
|
|
24
|
+
"revision": "f7441081114be94a22d6e1774194dc8bd7a88e18",
|
|
25
25
|
"distribution": "greenfield-project",
|
|
26
26
|
"descriptor": "assets/deployment/distribution.json",
|
|
27
27
|
"sourceRoot": "assets/generated/deployment",
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
{"source": "distribution/project-forge-deploy.py", "destination": "tools/project-forge-deployment/project-forge-deploy.py"},
|
|
7
7
|
{"source": "src/project_forge_deployment/__init__.py", "destination": "tools/project-forge-deployment/project_forge_deployment/__init__.py"},
|
|
8
8
|
{"source": "src/project_forge_deployment/__main__.py", "destination": "tools/project-forge-deployment/project_forge_deployment/__main__.py"},
|
|
9
|
+
{"source": "src/project_forge_deployment/access_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/access_cli.py"},
|
|
10
|
+
{"source": "src/project_forge_deployment/access_diagnosis.py", "destination": "tools/project-forge-deployment/project_forge_deployment/access_diagnosis.py"},
|
|
9
11
|
{"source": "src/project_forge_deployment/app_cli.py", "destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py"},
|
|
10
12
|
{"source": "src/project_forge_deployment/app_deployment.py", "destination": "tools/project-forge-deployment/project_forge_deployment/app_deployment.py"},
|
|
11
13
|
{"source": "src/project_forge_deployment/app_runtime.py", "destination": "tools/project-forge-deployment/project_forge_deployment/app_runtime.py"},
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.7.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
|
-
"revision": "
|
|
7
|
+
"revision": "737dab0636252410c022be057d25c36c1dd5f116",
|
|
8
8
|
"version": "4.0.0",
|
|
9
9
|
"distribution": "greenfield-workspace",
|
|
10
10
|
"files": [
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
{
|
|
22
22
|
"source": "core/instructions/project-forge-workflow.instructions.md",
|
|
23
23
|
"destination": ".github/instructions/project-forge-workflow.instructions.md",
|
|
24
|
-
"sha256": "
|
|
24
|
+
"sha256": "5b1fffa81451f92f29c839d1219168a72ce625a1d8eed952a9946adf0a66b84b"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"source": "core/instructions/project-forge-docs.instructions.md",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
{
|
|
52
52
|
"source": "core/skills/setup/SKILL.md",
|
|
53
53
|
"destination": ".github/skills/setup/SKILL.md",
|
|
54
|
-
"sha256": "
|
|
54
|
+
"sha256": "27419e0d330abf2b6b03a696eba352ab2ece13a616c41fc0ade9aed85e8834f1"
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
"source": "core/skills/build/SKILL.md",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
{
|
|
187
187
|
"source": "core/process/INTERACTIVE_SESSION_GUIDE.md",
|
|
188
188
|
"destination": "docs/process/INTERACTIVE_SESSION_GUIDE.md",
|
|
189
|
-
"sha256": "
|
|
189
|
+
"sha256": "159bd76122f49be9f2a31d5766fb8a06e9179caef8c6c3eb03bc750685fe73f6"
|
|
190
190
|
},
|
|
191
191
|
{
|
|
192
192
|
"source": "core/process/COACHING_GUIDE.md",
|
|
@@ -326,7 +326,7 @@
|
|
|
326
326
|
]
|
|
327
327
|
},
|
|
328
328
|
"deployment": {
|
|
329
|
-
"revision": "
|
|
329
|
+
"revision": "f7441081114be94a22d6e1774194dc8bd7a88e18",
|
|
330
330
|
"distribution": "greenfield-project",
|
|
331
331
|
"files": [
|
|
332
332
|
{
|
|
@@ -344,6 +344,16 @@
|
|
|
344
344
|
"destination": "tools/project-forge-deployment/project_forge_deployment/__main__.py",
|
|
345
345
|
"sha256": "8742c1b766812edb07174a724d676ffeff58b71b3c192d35102a3e652e962946"
|
|
346
346
|
},
|
|
347
|
+
{
|
|
348
|
+
"source": "src/project_forge_deployment/access_cli.py",
|
|
349
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/access_cli.py",
|
|
350
|
+
"sha256": "007d01ae0c4e28e64f75fb96f9e8e7b99db5c4c8129698a78923baeac2f51953"
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
"source": "src/project_forge_deployment/access_diagnosis.py",
|
|
354
|
+
"destination": "tools/project-forge-deployment/project_forge_deployment/access_diagnosis.py",
|
|
355
|
+
"sha256": "92861e58a29b17758a927f7a527b089f172a1bc0c9725c79731ef07e00294af1"
|
|
356
|
+
},
|
|
347
357
|
{
|
|
348
358
|
"source": "src/project_forge_deployment/app_cli.py",
|
|
349
359
|
"destination": "tools/project-forge-deployment/project_forge_deployment/app_cli.py",
|
|
@@ -397,7 +407,7 @@
|
|
|
397
407
|
{
|
|
398
408
|
"source": "src/project_forge_deployment/entrypoint.py",
|
|
399
409
|
"destination": "tools/project-forge-deployment/project_forge_deployment/entrypoint.py",
|
|
400
|
-
"sha256": "
|
|
410
|
+
"sha256": "6b767380f502acf0763b71f46aff6efd62674e63a526ed270097efa68c320a1e"
|
|
401
411
|
},
|
|
402
412
|
{
|
|
403
413
|
"source": "src/project_forge_deployment/environment_cli.py",
|
|
@@ -512,7 +522,7 @@
|
|
|
512
522
|
{
|
|
513
523
|
"source": "src/project_forge_deployment/security.py",
|
|
514
524
|
"destination": "tools/project-forge-deployment/project_forge_deployment/security.py",
|
|
515
|
-
"sha256": "
|
|
525
|
+
"sha256": "d24d4faa0d98b2e7484e2a9e959ba4624927be61a84453ed2db150aa6132c0cf"
|
|
516
526
|
},
|
|
517
527
|
{
|
|
518
528
|
"source": "src/project_forge_deployment/sqlite_adapter.py",
|
|
@@ -22,6 +22,8 @@ Continuous modes such as `/grill-me`, `/refine-requirements`, `/coach`, `/learni
|
|
|
22
22
|
|
|
23
23
|
During interactive questioning, prefer concise choice-first options for genuinely bounded human decisions, including `Other / specify` and `Not sure — recommend one` when appropriate. When the host exposes native structured question controls, use them instead of a plain numbered chat list. In VS Code, use the host's structured question surface such as `vscode_askQuestions` when available: single-select for mutually exclusive decisions/actions and multi-select only when several simultaneous selections are valid. A textual numbered or lettered menu is only the fallback when structured choice UI is unavailable.
|
|
24
24
|
|
|
25
|
+
When an authorized action requires a developer-owned interactive terminal prompt, follow the interactive-terminal handoff in `INTERACTIVE_SESSION_GUIDE.md`: collect any distinct bounded authorization first through the host-native structured control, then immediately provide the exact command in a standalone fenced code block, state the working directory when relevant, explain the local prompt briefly, keep passphrases/passwords/tokens/private keys local to the terminal, and say what result to report back for deterministic verification. Do not make the inability to inject terminal keystrokes the main response when a copyable command is sufficient. If the host can insert terminal text, insert only the non-secret command for human review/execution rather than silently executing the interactive step.
|
|
26
|
+
|
|
25
27
|
Make a recommendation only when current evidence, repository policy, or a clear engineering tradeoff supports one. Mark exactly one supported choice with the host's `recommended` indicator when available and give a brief rationale. When the host does not expose recommendation metadata, append `[RECOMMENDED]` to exactly one option label and keep the rationale visible. A default/preselected option may match the recommendation, but a default alone must never communicate advice or human approval. If no option is clearly better, do not mark any option recommended and do not use a default that implies a preference. A recommendation is advice, not approval: record only the human's selected/confirmed answer. If the human asks for no recommendation on one decision, honor that for that decision only; later questions may still include evidence-backed recommendations.
|
|
26
28
|
|
|
27
29
|
Keep factual/environment-specific values and questions intended to elicit the human's own framing, reasoning, debugging approach, design rationale, or understanding open-ended unless options are later useful as scaffolding.
|
|
@@ -30,6 +32,8 @@ Keep factual/environment-specific values and questions intended to elicit the hu
|
|
|
30
32
|
|
|
31
33
|
`/setup` is the single workstation/environment preparation entry point. It owns status, repair, verified local runtime convergence, remote-enabled capability preparation, and named-environment setup/proof. Do not create a second developer-environment-mode command. `/deployment-readiness <environment>` remains the later exact-environment pre-deploy verification step.
|
|
32
34
|
|
|
35
|
+
For `/setup`, apply the interactive-terminal handoff whenever deterministic Project Forge tooling reaches a local prompt that must remain under developer control. For example, after the exact target key-creation action is authorized, provide the returned `project-forge-deploy target key-create <target>` command immediately in a standalone copyable block, tell the developer to enter `[LOCAL KEY WRITE]` and the private-key passphrase only in the terminal, and state the non-secret result/evidence to report back so setup can verify and continue. Do not ask the developer to paste the passphrase or private key into chat and do not turn terminal ownership into a dead-end explanation.
|
|
36
|
+
|
|
33
37
|
For deployment readiness, inspect existing non-secret configuration before asking setup questions. Show already-known simple machine facts such as hostname/address, deployment user, port, and target OS for confirmation. Ask only for missing or conflicting facts. Keep deployment-tool implementation identifiers such as logical target names, `targetName`, SSH aliases, profile paths, and trust-file paths internal unless troubleshooting or genuine ambiguity requires them.
|
|
34
38
|
|
|
35
39
|
Shared managed code keeps its canonical owner. When a consumer contains `CORE_BASELINE.json` or `DEPLOYMENT_BASELINE.json`, treat those baselines as ownership evidence. A temporary edit to a shared-managed consumer file may be used to diagnose or prove a reusable defect, but it is not the canonical deliverable.
|
|
@@ -64,6 +64,17 @@ A structured selector is presentation, not extra authorization. Selecting a choi
|
|
|
64
64
|
|
|
65
65
|
When the host has no structured question UI, use a concise textual fallback. A numbered or lettered list is acceptable **only as this fallback**; keep the same labels/recommendation/consequence tags, including `[RECOMMENDED]` when one option is evidence-backed, and accept the number/letter, label, or a free-form answer. Do not use a plain numbered list as the normal VS Code interaction when native structured choice UI is available.
|
|
66
66
|
|
|
67
|
+
### Interactive terminal handoff
|
|
68
|
+
|
|
69
|
+
When an authorized action must run in the developer's own terminal because it requires an interactive prompt, secure local input, or another terminal-owned interaction, treat the command handoff as continuation of the workflow rather than as a generic blocker.
|
|
70
|
+
|
|
71
|
+
- If the action has its own bounded authorization, collect that authorization first through the host-native structured question control when available. Once the exact action is authorized, do not ask for the same permission again merely because execution moves to the terminal.
|
|
72
|
+
- Immediately after authorization, provide the exact command in a **standalone fenced code block**. State the working directory when it is not already unambiguous. Do not bury the command inside explanatory prose.
|
|
73
|
+
- Briefly describe the local prompt the developer should expect and which sensitive values must be entered **only in the terminal**. Never ask for a passphrase, password, token, private key, or other secret through chat, and never place such a secret in the command arguments merely to avoid an interactive prompt.
|
|
74
|
+
- State what observable result or concise evidence the developer should report back so the workflow can verify the action and continue. Prefer deterministic verification after the local step instead of asking for secret-bearing terminal transcripts.
|
|
75
|
+
- Do not lead with or dwell on the assistant's inability to send keystrokes when a copyable command is an adequate path forward. Capability limitations may be stated briefly only when they materially affect the next action.
|
|
76
|
+
- If the host exposes terminal-send support, it may insert the exact non-secret command for **human review/execution** rather than silently executing an interactive command. Keep interactive secret input local and never inject it through the host automation channel.
|
|
77
|
+
|
|
67
78
|
Keep questions open-ended when predefined choices would weaken the purpose of the interaction. This includes:
|
|
68
79
|
|
|
69
80
|
- user-authored problem/outcome/workflow framing where the human's own wording matters;
|
|
@@ -65,6 +65,14 @@ Do not require another workstation to be online and do not treat another compute
|
|
|
65
65
|
|
|
66
66
|
Detect the current OS automatically. Do not ask the developer which setup launcher to use when the platform can be observed.
|
|
67
67
|
|
|
68
|
+
If the observed workstation OS is not declared in mutable `DEV_ENVIRONMENT.json`, surface that explicitly before claiming setup support:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
NEW WORKSTATION — PLATFORM NOT DECLARED
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Do not silently reinterpret Bootstrap provenance or append a platform merely because tools happen to run. If current repository/catalog evidence supports the stack on the observed OS, offer one reviewed **consumer-owned** platform expansion to `DEV_ENVIRONMENT.json`; otherwise report the concrete unsupported/capability gap. Preserve original `PROJECT_BOOTSTRAP.json` provenance.
|
|
75
|
+
|
|
68
76
|
A workspace instance owns machine-local state such as local runtime/data, SSH private keys/trust, credential-manager entries, Docker state, and repository-scoped readiness evidence. Git/GitHub is the shared source-code coordination boundary. Never invent a central workstation registry merely to coordinate another computer.
|
|
69
77
|
|
|
70
78
|
## 2. `status` is read-only
|
|
@@ -112,6 +120,10 @@ setup --check
|
|
|
112
120
|
|
|
113
121
|
A manually invoked `/setup` authorizes routine, bounded, non-destructive local setup that the generated script already owns: supported tool installation, VS Code extension installation, dependency restore, personal Project Forge profile initialization, and other declared workstation preparation.
|
|
114
122
|
|
|
123
|
+
Before a large workstation install run, surface all currently knowable prerequisites together when the generated tooling exposes them: missing tools, runtime/version pins, editor extensions, Docker operational state, interactive elevation/terminal requirements, and durable fresh-shell discovery. Group unavoidable sudo/administrator or secret-bearing prompts into clear terminal handoffs instead of discovering them one installer at a time.
|
|
124
|
+
|
|
125
|
+
A temporary process `PATH` adjustment may prove a diagnosis but does not by itself prove durable tool discovery. Re-check important runtime commands in the environment a fresh terminal/editor will use before reporting final readiness.
|
|
126
|
+
|
|
115
127
|
Stop before destructive replacement, unrelated machine mutation, secret disclosure, or repair outside the declared setup contract.
|
|
116
128
|
|
|
117
129
|
## 4. `/setup local` converges verified local development readiness
|
|
@@ -132,7 +144,7 @@ When application source/runtime exists, converge only the repository-owned local
|
|
|
132
144
|
|
|
133
145
|
Verify supported tools/versions using repository setup/doctor tooling when present. Run repository-defined dependency restore/setup commands when missing or stale. Preserve pinned versions and project conventions.
|
|
134
146
|
|
|
135
|
-
Treat **installation**, **version compatibility**, and **operational readiness** as separate evidence when tooling exposes those distinctions. A successful CLI/version probe does not prove that the backing service/runtime is operational. For Docker-based setup, do not call the runtime ready merely because `docker --version` succeeds; require the repository/doctor operational probe before relying on Docker Engine capability.
|
|
147
|
+
Treat **installation**, **version compatibility**, and **operational readiness** as separate evidence when tooling exposes those distinctions. A successful CLI/version probe does not prove that the backing service/runtime is operational for the required Project Forge action. For Docker-based setup, do not call the runtime ready merely because `docker --version` succeeds; require the repository/doctor operational probe before relying on Docker Engine capability.
|
|
136
148
|
|
|
137
149
|
### B. Local persistence
|
|
138
150
|
|
|
@@ -266,6 +278,26 @@ For remote SSH preserve strict host identity. Unknown/changed host identity is a
|
|
|
266
278
|
|
|
267
279
|
Remote readiness does not require configuring Production when the selected environment is non-Production. Production classification remains project topology/policy, not a literal-name check.
|
|
268
280
|
|
|
281
|
+
### One read-only remote preflight before recovery
|
|
282
|
+
|
|
283
|
+
Before asking the developer to repair one SSH prerequisite at a time, collect all currently knowable target-access facts through supported deterministic status/diagnosis surfaces. When installed Deployment Kit exposes the access diagnosis command, prefer:
|
|
284
|
+
|
|
285
|
+
```text
|
|
286
|
+
project-forge-deploy access diagnose <target> --json
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
If stable name resolution is unavailable and the developer supplies an exact address independently observed at the trusted target console, carry that address through read-only diagnosis without persisting it:
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
project-forge-deploy access diagnose <target> --address <exact-ip> --json
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The exact IP is address-path evidence, not a replacement SSH identity. Preserve the configured `HostKeyAlias`, target-specific trust, and configured identity. Do not store the address merely because it made diagnosis possible. Persist it only through the separately gated verified-address operation after strict SSH succeeds.
|
|
296
|
+
|
|
297
|
+
When diagnosis exposes route-source evidence, use the actual selected local source address/interface for any source-policy discussion. Never assume Wi-Fi, Ethernet, the first interface, or a subnet merely from workstation configuration.
|
|
298
|
+
|
|
299
|
+
If the Deployment Kit version does not expose the supported diagnosis surface, use its documented structured environment status/plan commands. Do not substitute ad hoc Python imports or unbounded raw `ssh -vvv` transcripts as normal orchestration.
|
|
300
|
+
|
|
269
301
|
### Guided remote failure recovery is part of `/setup`
|
|
270
302
|
|
|
271
303
|
A supported deterministic setup/readiness failure is not, by itself, the end of `/setup`. When installed Deployment Kit tooling returns structured diagnosis or a bounded next action, continue through that diagnosis automatically until one of these boundaries is reached:
|
|
@@ -278,7 +310,7 @@ A supported deterministic setup/readiness failure is not, by itself, the end of
|
|
|
278
310
|
|
|
279
311
|
Do **not** merely repeat an opaque subprocess exit code and tell the developer to fix access independently when deterministic diagnosis is available.
|
|
280
312
|
|
|
281
|
-
|
|
313
|
+
Preserve distinct categories such as:
|
|
282
314
|
|
|
283
315
|
```text
|
|
284
316
|
name resolution / address path
|
|
@@ -293,8 +325,45 @@ remote account/source-policy rejection
|
|
|
293
325
|
|
|
294
326
|
Keep credentials, private keys, raw secure-prompt content, and unbounded SSH transcripts out of conversational diagnostics and journals.
|
|
295
327
|
|
|
328
|
+
For fingerprint input, show the expected public form such as `SHA256:<fingerprint>` and validate that shape before progressing. Distinguish the **server host fingerprint**, the **client public-key fingerprint**, and passwords/passphrases. If input looks credential-like rather than fingerprint-shaped, do not repeat it into commands, reports, journals, or upstream issues.
|
|
329
|
+
|
|
330
|
+
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.
|
|
331
|
+
|
|
296
332
|
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.
|
|
297
333
|
|
|
334
|
+
### One bounded trusted-console diagnostic bundle
|
|
335
|
+
|
|
336
|
+
When client evidence reaches the server but cannot establish why the account/session is rejected, request one compact server-side evidence bundle instead of a chain of one-fact-at-a-time questions. Limit the bundle to the facts needed for the selected account and actual route-selected source:
|
|
337
|
+
|
|
338
|
+
- intended SSH account;
|
|
339
|
+
- whether the expected **client public-key fingerprint** is present for that account, without dumping unrelated authorized keys;
|
|
340
|
+
- ownership/modes for the account's `.ssh` directory and `authorized_keys`;
|
|
341
|
+
- a narrowly scoped recent authentication reason for the attempted source/account;
|
|
342
|
+
- effective SSH allow/deny policy relevant to that account and source;
|
|
343
|
+
- the file/include/`Match` location that owns any relevant directive.
|
|
344
|
+
|
|
345
|
+
State exactly where to run each console command and what non-secret output to return. Never request full SSH configs/logs, passwords, private keys, tokens, or unrelated authorized-key material when the focused bundle is enough.
|
|
346
|
+
|
|
347
|
+
Client evidence may classify `remote-authentication-rejected` or `remote-account-policy-rejected`, but only trusted server-side evidence may establish a concrete cause such as `AllowUsers`.
|
|
348
|
+
|
|
349
|
+
### One exact server-policy repair after evidence
|
|
350
|
+
|
|
351
|
+
If the trusted-console evidence establishes that a server-side account/source policy change is required, prepare **one exact repair plan** before any write. The plan must show:
|
|
352
|
+
|
|
353
|
+
- exact affected file/directive and relevant before/after state;
|
|
354
|
+
- the actual durable source address/interface assumption being authorized;
|
|
355
|
+
- preservation of existing allow/deny entries unless the operator explicitly chooses otherwise;
|
|
356
|
+
- an exact backup of the affected file before editing;
|
|
357
|
+
- syntax/config validation before reload;
|
|
358
|
+
- preservation of the current trusted administrative session until new strict access is proven;
|
|
359
|
+
- reload/restart scope, rollback command, and explicit stop conditions.
|
|
360
|
+
|
|
361
|
+
Do not present configuration text as if it were a shell command. Give either one copyable bounded command or explicit editor instructions on the **target host**, with the file path stated. Do not assume that adding a new drop-in overrides or composes safely with existing includes/`Match` blocks. Never broaden a specific source restriction to a subnet merely for convenience.
|
|
362
|
+
|
|
363
|
+
A server-policy write remains a distinct operator/administrative authorization. Once the exact reviewed repair is authorized and the human reports it completed, do not ask the same authorization again: automatically re-run strict structured diagnosis/verification, then continue through separately gated address persistence, host-platform readiness, and proof as applicable.
|
|
364
|
+
|
|
365
|
+
If the human says stop, done, or pause at any point, stop modifying state. Read-only reporting may summarize what is already known, but newly observed prerequisite changes do not override the expressed pause.
|
|
366
|
+
|
|
298
367
|
### Gate password-assisted bootstrap on server capability
|
|
299
368
|
|
|
300
369
|
Before recommending a password-assisted/Home Lab bootstrap against a reachable server, use deterministic evidence of the server-advertised authentication methods when the installed Deployment Kit supports that probe.
|
|
@@ -355,6 +424,8 @@ For a Production-class environment, keep the proof explicitly labeled as a **PRO
|
|
|
355
424
|
|
|
356
425
|
If cleanup fails, report `PROOF PASSED — CLEANUP ACTION NEEDED`; do not claim a clean setup.
|
|
357
426
|
|
|
427
|
+
When proof output distinguishes `transferState: transferred` from `transferState: already-present`, report that distinction. A proof that reused an exact cached image may still prove runtime/health/cleanup, but it must not be described as an observed fresh workstation-to-target image transfer. If fresh transport itself must be tested, plan that separately without deleting or pruning a shared image cache merely to manufacture evidence.
|
|
428
|
+
|
|
358
429
|
When installed Deployment Kit provides a durable local proof receipt or equivalent discoverable evidence, record/reuse it rather than relying on chat history. Reuse only while its target binding, host identity, workstation identity, adapter/provenance, logical environment, and cleanup result remain applicable. A new chat alone must not invalidate valid machine evidence.
|
|
359
430
|
|
|
360
431
|
## 9. `repair` is bounded and fail-closed
|
|
@@ -382,9 +453,27 @@ Stop for a separate explicit decision before:
|
|
|
382
453
|
|
|
383
454
|
## 11. Completion and handoff
|
|
384
455
|
|
|
385
|
-
After setup, report evidence rather than inferring human acceptance.
|
|
456
|
+
After setup, report evidence rather than inferring human acceptance. Keep independent readiness dimensions independent instead of collapsing them into one `READY` label.
|
|
457
|
+
|
|
458
|
+
Prefer a compact final shape such as:
|
|
459
|
+
|
|
460
|
+
```text
|
|
461
|
+
SETUP RESULT
|
|
462
|
+
|
|
463
|
+
Local tools/runtime ......... VERIFIED / BLOCKED / NOT APPLICABLE
|
|
464
|
+
Fresh-shell discovery ....... VERIFIED / CAVEAT / BLOCKED
|
|
465
|
+
Build/tests ................. VERIFIED / BLOCKED / NOT RUN
|
|
466
|
+
Remote target access ........ VERIFIED / BLOCKED / NOT CONFIGURED
|
|
467
|
+
Host platform ............... READY / BLOCKED / NOT APPLICABLE
|
|
468
|
+
Deployment path ............. PROVEN / NOT PROVEN / BLOCKED
|
|
469
|
+
Proof image transfer ........ TRANSFERRED / CACHE REUSED / NOT RUN
|
|
470
|
+
Proof cleanup ............... CLEAN / ACTION NEEDED / NOT RUN
|
|
471
|
+
Project application ......... NOT DEPLOYED / <actual evidence>
|
|
472
|
+
Repository CI ............... PASS / BLOCKED / NOT CHECKED
|
|
473
|
+
Human workspace acceptance .. CONFIRMED / PENDING
|
|
474
|
+
```
|
|
386
475
|
|
|
387
|
-
A generated setup/doctor/proof success is machine evidence. Human workspace acceptance remains distinct.
|
|
476
|
+
A generated setup/doctor/proof success is machine evidence. Human workspace acceptance remains distinct. A continuation/override for a documented Project Forge defect is not a CI waiver and is not human workspace acceptance; preserve its exact scope so resuming setup does not repeatedly ask the same decision.
|
|
388
477
|
|
|
389
478
|
Useful next actions include:
|
|
390
479
|
- `/next-step` — one sensible next action after current evidence is known;
|
|
@@ -393,4 +482,4 @@ Useful next actions include:
|
|
|
393
482
|
- `/deploy <environment>` — only after readiness and its own authorization contract;
|
|
394
483
|
- `/tear-down <scope>` — destructive cleanup under its separate gate.
|
|
395
484
|
|
|
396
|
-
Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
|
|
485
|
+
Do not create a second top-level developer-environment-mode workflow. Local/remote workstation convergence belongs here.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""CLI for strict target-access diagnosis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .access_diagnosis import diagnose_target_access
|
|
11
|
+
from .errors import DeploymentKitError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _print(value: Any, *, as_json: bool) -> None:
|
|
15
|
+
if as_json:
|
|
16
|
+
print(json.dumps(value, indent=2, sort_keys=True))
|
|
17
|
+
return
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
for key, item in value.items():
|
|
20
|
+
if isinstance(item, (dict, list)):
|
|
21
|
+
print(f"{key}: {json.dumps(item, sort_keys=True)}")
|
|
22
|
+
else:
|
|
23
|
+
print(f"{key}: {item}")
|
|
24
|
+
return
|
|
25
|
+
print(value)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="project-forge-deploy access",
|
|
31
|
+
description="Read-only strict SSH target-access diagnosis.",
|
|
32
|
+
)
|
|
33
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
34
|
+
|
|
35
|
+
diagnose = subparsers.add_parser(
|
|
36
|
+
"diagnose",
|
|
37
|
+
help="Diagnose one configured target without mutating target or workstation state",
|
|
38
|
+
)
|
|
39
|
+
diagnose.add_argument("name")
|
|
40
|
+
diagnose.add_argument(
|
|
41
|
+
"--address",
|
|
42
|
+
help=(
|
|
43
|
+
"Exact temporary IPv4/IPv6 address independently confirmed by the operator. "
|
|
44
|
+
"Used only for this read-only diagnosis and never persisted."
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
diagnose.add_argument("--json", action="store_true")
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
args = build_parser().parse_args(argv)
|
|
53
|
+
try:
|
|
54
|
+
if args.command == "diagnose":
|
|
55
|
+
result = diagnose_target_access(args.name, temporary_address=args.address)
|
|
56
|
+
_print(result, as_json=args.json)
|
|
57
|
+
return 0 if result.get("state") == "verified" else 2
|
|
58
|
+
raise DeploymentKitError("Unsupported access command.")
|
|
59
|
+
except DeploymentKitError as error:
|
|
60
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Read-only strict target-access diagnosis with optional temporary address evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import DeploymentKitError
|
|
15
|
+
from .profiles import load_profile
|
|
16
|
+
from .ssh import diagnose_access
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _temporary_address(value: str | None) -> str | None:
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
parsed = ipaddress.ip_address(value)
|
|
24
|
+
except ValueError as error:
|
|
25
|
+
raise DeploymentKitError("Temporary target address must be an exact IPv4 or IPv6 address.") from error
|
|
26
|
+
if isinstance(parsed, ipaddress.IPv6Address) and parsed.scope_id is not None:
|
|
27
|
+
raise DeploymentKitError("Temporary target address must not include an IPv6 scope identifier.")
|
|
28
|
+
return str(parsed)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _route_interface(address: str) -> str | None:
|
|
32
|
+
if sys.platform == "darwin":
|
|
33
|
+
route = shutil.which("route") or ("/sbin/route" if os.path.exists("/sbin/route") else None)
|
|
34
|
+
if route:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
[route, "-n", "get", address],
|
|
37
|
+
check=False,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
)
|
|
43
|
+
if result.returncode == 0:
|
|
44
|
+
match = re.search(r"^\s*interface:\s*(\S+)\s*$", result.stdout, re.MULTILINE)
|
|
45
|
+
if match:
|
|
46
|
+
return match.group(1)
|
|
47
|
+
elif os.name != "nt":
|
|
48
|
+
ip = shutil.which("ip")
|
|
49
|
+
if ip:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
[ip, "route", "get", address],
|
|
52
|
+
check=False,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
errors="replace",
|
|
57
|
+
)
|
|
58
|
+
if result.returncode == 0:
|
|
59
|
+
match = re.search(r"\bdev\s+(\S+)", result.stdout)
|
|
60
|
+
if match:
|
|
61
|
+
return match.group(1)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def route_source_evidence(address: str, port: int) -> dict[str, Any]:
|
|
66
|
+
"""Return the local route-selected source for one exact target address without scanning."""
|
|
67
|
+
parsed = ipaddress.ip_address(address)
|
|
68
|
+
family = socket.AF_INET6 if isinstance(parsed, ipaddress.IPv6Address) else socket.AF_INET
|
|
69
|
+
evidence: dict[str, Any] = {
|
|
70
|
+
"targetAddress": str(parsed),
|
|
71
|
+
"targetPort": int(port),
|
|
72
|
+
"sourceAddress": None,
|
|
73
|
+
"interface": _route_interface(str(parsed)),
|
|
74
|
+
}
|
|
75
|
+
try:
|
|
76
|
+
with socket.socket(family, socket.SOCK_DGRAM) as probe:
|
|
77
|
+
probe.connect((str(parsed), int(port)))
|
|
78
|
+
evidence["sourceAddress"] = probe.getsockname()[0]
|
|
79
|
+
except OSError as error:
|
|
80
|
+
evidence["sourceProbeError"] = str(error)
|
|
81
|
+
return evidence
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def diagnose_target_access(name: str, *, temporary_address: str | None = None) -> dict[str, Any]:
|
|
85
|
+
"""Diagnose strict SSH access without persisting a temporary address override."""
|
|
86
|
+
profile = load_profile(name)
|
|
87
|
+
address = _temporary_address(temporary_address)
|
|
88
|
+
effective = dict(profile)
|
|
89
|
+
if address is not None:
|
|
90
|
+
effective["addressOverride"] = address
|
|
91
|
+
|
|
92
|
+
diagnosis = diagnose_access(effective)
|
|
93
|
+
used_address = diagnosis.get("address")
|
|
94
|
+
if used_address is None and address is not None:
|
|
95
|
+
used_address = address
|
|
96
|
+
|
|
97
|
+
result: dict[str, Any] = {
|
|
98
|
+
**diagnosis,
|
|
99
|
+
"addressInput": (
|
|
100
|
+
{
|
|
101
|
+
"source": "temporary-operator-confirmed",
|
|
102
|
+
"address": address,
|
|
103
|
+
"persisted": False,
|
|
104
|
+
}
|
|
105
|
+
if address is not None
|
|
106
|
+
else {
|
|
107
|
+
"source": "profile",
|
|
108
|
+
"address": profile.get("addressOverride"),
|
|
109
|
+
"persisted": profile.get("addressOverride") is not None,
|
|
110
|
+
}
|
|
111
|
+
),
|
|
112
|
+
"mutated": False,
|
|
113
|
+
}
|
|
114
|
+
if used_address is not None:
|
|
115
|
+
result["routeSource"] = route_source_evidence(used_address, int(profile["port"]))
|
|
116
|
+
return result
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
6
|
|
|
7
|
+
from .access_cli import main as access_main
|
|
7
8
|
from .app_cli import main as app_main
|
|
8
9
|
from .cli import main as core_main
|
|
9
10
|
from .environment_cli import main as environment_main
|
|
@@ -16,6 +17,8 @@ from .topology_cli import main as topology_main
|
|
|
16
17
|
|
|
17
18
|
def main(argv: list[str] | None = None) -> int:
|
|
18
19
|
args = list(sys.argv[1:] if argv is None else argv)
|
|
20
|
+
if args and args[0] == "access":
|
|
21
|
+
return access_main(args[1:])
|
|
19
22
|
if args and args[0] == "app":
|
|
20
23
|
return app_main(args[1:])
|
|
21
24
|
if args and args[0] == "environment":
|
|
@@ -31,6 +34,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
31
34
|
if args and args[0] == "topology":
|
|
32
35
|
return topology_main(args[1:])
|
|
33
36
|
if not args or args == ["--help"] or args == ["-h"]:
|
|
37
|
+
print("Command group: access Read-only strict SSH target-access diagnosis")
|
|
34
38
|
print("Command group: app Remote immutable application runtime inventory/deployment")
|
|
35
39
|
print("Command group: environment Portable profiles, Home Lab onboarding, and image transfer")
|
|
36
40
|
print("Command group: local Loopback-only workstation application runtime")
|
|
@@ -180,27 +180,56 @@ def _posix_directory_chain(path: Path) -> list[Path]:
|
|
|
180
180
|
current = current.parent
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
def
|
|
183
|
+
def _posix_directory_issues(path: Path) -> list[str]:
|
|
184
|
+
issues: list[str] = []
|
|
185
|
+
current_uid = os.getuid()
|
|
184
186
|
for directory in _posix_directory_chain(path):
|
|
185
|
-
if directory.is_symlink()
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
if directory.is_symlink():
|
|
188
|
+
issues.append(f"symbolic link: {directory}")
|
|
189
|
+
continue
|
|
190
|
+
if not directory.exists():
|
|
191
|
+
continue
|
|
192
|
+
if not directory.is_dir():
|
|
193
|
+
issues.append(f"not a directory: {directory}")
|
|
194
|
+
continue
|
|
195
|
+
details = directory.stat()
|
|
196
|
+
if details.st_uid != current_uid:
|
|
197
|
+
issues.append(f"not owned by the current user: {directory}")
|
|
198
|
+
if details.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
|
199
|
+
issues.append(f"permissions are too broad: {directory}")
|
|
200
|
+
return issues
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_posix_directories(path: Path) -> None:
|
|
204
|
+
issues = _posix_directory_issues(path)
|
|
205
|
+
if issues:
|
|
206
|
+
raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
|
|
189
207
|
|
|
190
208
|
|
|
191
209
|
def prepare_ssh_directory(path: Path) -> None:
|
|
192
|
-
"""Create
|
|
210
|
+
"""Create missing owned SSH directories safely without silently repairing existing unsafe state."""
|
|
193
211
|
_ssh_directory_for(path)
|
|
194
|
-
if
|
|
195
|
-
if path.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
path.chmod(0o700)
|
|
201
|
-
_check_posix_directories(path / "placeholder")
|
|
202
|
-
else:
|
|
212
|
+
if os.name == "nt":
|
|
213
|
+
if path.exists():
|
|
214
|
+
if path.is_symlink() or not path.is_dir():
|
|
215
|
+
raise DeploymentKitError(f"SSH material directory is not a real directory: {path}")
|
|
216
|
+
else:
|
|
217
|
+
path.mkdir(parents=True, mode=0o700)
|
|
203
218
|
_check_windows_acl(path)
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
probe = path / "placeholder"
|
|
222
|
+
issues = _posix_directory_issues(probe)
|
|
223
|
+
if issues:
|
|
224
|
+
raise DeploymentKitError("SSH directory chain is unsafe: " + "; ".join(issues))
|
|
225
|
+
|
|
226
|
+
for directory in reversed(_posix_directory_chain(probe)):
|
|
227
|
+
if directory.exists():
|
|
228
|
+
continue
|
|
229
|
+
directory.mkdir(mode=0o700)
|
|
230
|
+
directory.chmod(0o700)
|
|
231
|
+
|
|
232
|
+
_check_posix_directories(probe)
|
|
204
233
|
|
|
205
234
|
|
|
206
235
|
def check_identity_security(identity: Path) -> None:
|
package/package.json
CHANGED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const MANIFEST_RELATIVE = 'assets/bootstrap-migrations/managed-baseline-verifier/manifest.json';
|
|
6
|
+
const BOOTSTRAP_MANIFEST = 'PROJECT_BOOTSTRAP.json';
|
|
7
|
+
|
|
8
|
+
function readJson(file) {
|
|
9
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function gitBlobSha(data) {
|
|
13
|
+
const raw = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
14
|
+
const buffer = Buffer.from(raw.toString('utf8').replaceAll('\r\n', '\n'), 'utf8');
|
|
15
|
+
const header = Buffer.from(`blob ${buffer.length}\0`, 'utf8');
|
|
16
|
+
return crypto.createHash('sha1').update(header).update(buffer).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeRelative(value, label) {
|
|
20
|
+
if (typeof value !== 'string' || !value || path.isAbsolute(value)) {
|
|
21
|
+
throw new Error(`Unsafe ${label}: ${value}`);
|
|
22
|
+
}
|
|
23
|
+
const normalized = value.replaceAll('\\', '/');
|
|
24
|
+
if (normalized.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
25
|
+
throw new Error(`Unsafe ${label}: ${value}`);
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeConsumerPath(root, relative) {
|
|
31
|
+
const normalized = safeRelative(relative, 'Bootstrap migration destination');
|
|
32
|
+
const resolvedRoot = path.resolve(root);
|
|
33
|
+
const resolved = path.resolve(resolvedRoot, ...normalized.split('/'));
|
|
34
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
|
35
|
+
throw new Error(`Unsafe Bootstrap migration destination: ${relative}`);
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadManifest(packageData) {
|
|
41
|
+
const file = path.join(packageData.root, ...MANIFEST_RELATIVE.split('/'));
|
|
42
|
+
const manifest = readJson(file);
|
|
43
|
+
if (manifest.schemaVersion !== 1 || manifest.owner !== 'bootstrap') {
|
|
44
|
+
throw new Error('Invalid Bootstrap migration manifest ownership/schema');
|
|
45
|
+
}
|
|
46
|
+
if (manifest.source?.component !== 'bootstrap') {
|
|
47
|
+
throw new Error('Invalid Bootstrap migration logical source component');
|
|
48
|
+
}
|
|
49
|
+
if (!/^[0-9a-f]{40}$/.test(manifest.source?.revision ?? '')) {
|
|
50
|
+
throw new Error('Invalid Bootstrap migration source revision');
|
|
51
|
+
}
|
|
52
|
+
safeRelative(manifest.source?.artifact, 'Bootstrap migration source artifact');
|
|
53
|
+
if (!/^[0-9a-f]{40}$/.test(manifest.targetGitBlob ?? '')) {
|
|
54
|
+
throw new Error('Invalid Bootstrap migration target Git blob');
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(manifest.supportedPredecessorGitBlobs) || !manifest.supportedPredecessorGitBlobs.every((value) => /^[0-9a-f]{40}$/.test(value))) {
|
|
57
|
+
throw new Error('Invalid Bootstrap migration predecessor identities');
|
|
58
|
+
}
|
|
59
|
+
const assetRelative = safeRelative(manifest.asset, 'Bootstrap migration bundled asset');
|
|
60
|
+
const asset = path.join(packageData.root, ...assetRelative.split('/'));
|
|
61
|
+
const bytes = fs.readFileSync(asset);
|
|
62
|
+
if (gitBlobSha(bytes) !== manifest.targetGitBlob) {
|
|
63
|
+
throw new Error('Bundled Bootstrap migration asset identity does not match its manifest');
|
|
64
|
+
}
|
|
65
|
+
return { manifest, bytes };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function planBootstrapMigrations(packageData, consumerRoot) {
|
|
69
|
+
const bootstrapManifest = path.join(consumerRoot, BOOTSTRAP_MANIFEST);
|
|
70
|
+
if (!fs.existsSync(bootstrapManifest)) return { rows: [], unsafe: [] };
|
|
71
|
+
|
|
72
|
+
const { manifest } = loadManifest(packageData);
|
|
73
|
+
const destination = safeConsumerPath(consumerRoot, manifest.destination);
|
|
74
|
+
if (!fs.existsSync(destination)) {
|
|
75
|
+
return {
|
|
76
|
+
rows: [],
|
|
77
|
+
unsafe: [{
|
|
78
|
+
owner: 'bootstrap',
|
|
79
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
80
|
+
path: manifest.destination,
|
|
81
|
+
detail: 'Bootstrap-generated verifier is missing; refusing package mutation before a supported migration is established.'
|
|
82
|
+
}]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const stat = fs.lstatSync(destination);
|
|
87
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
88
|
+
return {
|
|
89
|
+
rows: [],
|
|
90
|
+
unsafe: [{
|
|
91
|
+
owner: 'bootstrap',
|
|
92
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
93
|
+
path: manifest.destination,
|
|
94
|
+
detail: 'Bootstrap-generated verifier is not a regular file; refusing package mutation.'
|
|
95
|
+
}]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const realRoot = fs.realpathSync(consumerRoot);
|
|
99
|
+
const realDestination = fs.realpathSync(destination);
|
|
100
|
+
if (realDestination !== realRoot && !realDestination.startsWith(`${realRoot}${path.sep}`)) {
|
|
101
|
+
return {
|
|
102
|
+
rows: [],
|
|
103
|
+
unsafe: [{
|
|
104
|
+
owner: 'bootstrap',
|
|
105
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
106
|
+
path: manifest.destination,
|
|
107
|
+
detail: 'Bootstrap-generated verifier resolves outside the Project root; refusing package mutation.'
|
|
108
|
+
}]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
113
|
+
if (currentGitBlob === manifest.targetGitBlob) return { rows: [], unsafe: [] };
|
|
114
|
+
if (!manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
115
|
+
return {
|
|
116
|
+
rows: [],
|
|
117
|
+
unsafe: [{
|
|
118
|
+
owner: 'bootstrap',
|
|
119
|
+
state: 'BOOTSTRAP_MIGRATION_UNSUPPORTED',
|
|
120
|
+
path: manifest.destination,
|
|
121
|
+
detail: `Unknown/custom Bootstrap verifier identity ${currentGitBlob}; refusing to overwrite it.`
|
|
122
|
+
}]
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
rows: [{
|
|
128
|
+
owner: 'bootstrap',
|
|
129
|
+
state: 'MIGRATE_BOOTSTRAP_VERIFIER',
|
|
130
|
+
path: manifest.destination,
|
|
131
|
+
fromGitBlob: currentGitBlob,
|
|
132
|
+
toGitBlob: manifest.targetGitBlob,
|
|
133
|
+
source: manifest.source
|
|
134
|
+
}],
|
|
135
|
+
unsafe: []
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function applyBootstrapMigrations(packageData, migrationPlan, consumerRoot) {
|
|
140
|
+
if (!migrationPlan?.rows?.length) return;
|
|
141
|
+
if (migrationPlan.unsafe?.length) throw new Error('Refusing unsafe Bootstrap migration plan');
|
|
142
|
+
const { manifest, bytes } = loadManifest(packageData);
|
|
143
|
+
for (const row of migrationPlan.rows) {
|
|
144
|
+
if (row.state !== 'MIGRATE_BOOTSTRAP_VERIFIER' || row.path !== manifest.destination) {
|
|
145
|
+
throw new Error(`Unsupported Bootstrap migration row: ${row.state ?? 'unknown'}`);
|
|
146
|
+
}
|
|
147
|
+
const destination = safeConsumerPath(consumerRoot, row.path);
|
|
148
|
+
if (!fs.existsSync(destination) || fs.lstatSync(destination).isSymbolicLink() || !fs.lstatSync(destination).isFile()) {
|
|
149
|
+
throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
|
|
150
|
+
}
|
|
151
|
+
const currentGitBlob = gitBlobSha(fs.readFileSync(destination));
|
|
152
|
+
if (currentGitBlob !== row.fromGitBlob || !manifest.supportedPredecessorGitBlobs.includes(currentGitBlob)) {
|
|
153
|
+
throw new Error(`Bootstrap verifier changed after preview: ${row.path}`);
|
|
154
|
+
}
|
|
155
|
+
fs.writeFileSync(destination, bytes);
|
|
156
|
+
if (gitBlobSha(fs.readFileSync(destination)) !== manifest.targetGitBlob) {
|
|
157
|
+
throw new Error(`Bootstrap verifier migration verification failed: ${row.path}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { execFileSync } from 'node:child_process';
|
|
4
4
|
import readline from 'node:readline/promises';
|
|
5
5
|
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
+
import { applyBootstrapMigrations, planBootstrapMigrations } from './bootstrap-migrations.mjs';
|
|
6
7
|
import { applyProfile, checkProfile, loadProjectForgeBaseline, planProfile } from './engine.mjs';
|
|
7
8
|
import { loadPackageData } from './package-data.mjs';
|
|
8
9
|
|
|
@@ -43,15 +44,20 @@ function inferConsumer(root) {
|
|
|
43
44
|
return path.basename(root);
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function printPlan(plan) {
|
|
47
|
+
function printPlan(plan, bootstrapMigrations = { rows: [], unsafe: [] }) {
|
|
47
48
|
console.log(`Project Forge profile: ${plan.profileName}`);
|
|
49
|
+
if (bootstrapMigrations.rows.length) {
|
|
50
|
+
console.log('\nBOOTSTRAP MIGRATIONS');
|
|
51
|
+
for (const row of bootstrapMigrations.rows) console.log(`${row.state.padEnd(30)} ${row.path}`);
|
|
52
|
+
}
|
|
48
53
|
for (const component of plan.plans) {
|
|
49
54
|
console.log(`\n${component.componentId.toUpperCase()} ${component.component.commit}`);
|
|
50
55
|
for (const row of component.rows) console.log(`${row.state.padEnd(20)} ${row.path}`);
|
|
51
56
|
for (const retired of component.retired) console.log(`${retired.state.padEnd(20)} ${retired.path}`);
|
|
52
57
|
}
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
const unsafe = [...bootstrapMigrations.unsafe, ...plan.unsafe];
|
|
59
|
+
console.log(`\nUnsafe drift/conflicts: ${unsafe.length}`);
|
|
60
|
+
for (const item of unsafe) console.log(` - ${item.owner ?? item.component}: ${item.state}: ${item.path}${item.detail ? ` — ${item.detail}` : ''}`);
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
async function shouldApply(options) {
|
|
@@ -113,13 +119,17 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
113
119
|
|
|
114
120
|
if (command === 'check') {
|
|
115
121
|
const result = checkProfile(packageData, profile, root);
|
|
122
|
+
const bootstrapMigrations = planBootstrapMigrations(packageData, root);
|
|
123
|
+
const bootstrapCurrent = bootstrapMigrations.rows.length === 0 && bootstrapMigrations.unsafe.length === 0;
|
|
116
124
|
console.log(`Project Forge package baseline current: ${result.projectForgeCurrent ? 'yes' : 'no'}`);
|
|
125
|
+
console.log(`Bootstrap generated verifier: ${bootstrapCurrent ? 'PASS' : 'STALE OR UNSUPPORTED'}`);
|
|
117
126
|
for (const component of result.plan.plans) {
|
|
118
127
|
const clean = component.metadataCurrent && component.rows.every((row) => row.state === 'SAME') && component.retired.length === 0;
|
|
119
128
|
console.log(`${component.componentId}: ${clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
120
129
|
}
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
const clean = result.clean && bootstrapCurrent;
|
|
131
|
+
console.log(`Project Forge check: ${clean ? 'PASS' : 'STALE OR DRIFTED'}`);
|
|
132
|
+
return clean ? 0 : 1;
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
if (!['install', 'update'].includes(command)) throw new Error(`Unknown command: ${command}`);
|
|
@@ -127,10 +137,11 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
127
137
|
if (command === 'update' && !Object.keys(installed).length) throw new Error('PROJECT_FORGE_BASELINE.json is missing; use install first');
|
|
128
138
|
if (installed.profile && installed.profile !== profile) throw new Error(`Installed Project Forge profile is ${installed.profile}; refusing implicit profile change to ${profile}`);
|
|
129
139
|
|
|
140
|
+
const bootstrapMigrations = planBootstrapMigrations(packageData, root);
|
|
130
141
|
const plan = planProfile(packageData, profile, root, options.acceptedRetired);
|
|
131
|
-
printPlan(plan);
|
|
132
|
-
if (plan.unsafe.length) {
|
|
133
|
-
console.log('\nRefusing apply. Resolve drift/conflicts and preview again. No managed files were changed.');
|
|
142
|
+
printPlan(plan, bootstrapMigrations);
|
|
143
|
+
if (plan.unsafe.length || bootstrapMigrations.unsafe.length) {
|
|
144
|
+
console.log('\nRefusing apply. Resolve drift/conflicts or the unsupported Bootstrap migration and preview again. No managed files were changed.');
|
|
134
145
|
return 2;
|
|
135
146
|
}
|
|
136
147
|
const apply = await shouldApply(options);
|
|
@@ -144,6 +155,7 @@ export async function run(argv = process.argv.slice(2), packageData = loadPackag
|
|
|
144
155
|
if (baselineLabels.length > 1) throw new Error(`Component baselines disagree on consumer baseline: ${baselineLabels.join(', ')}`);
|
|
145
156
|
const consumer = options.consumer ?? installed.consumer ?? baselineConsumers[0] ?? inferConsumer(root);
|
|
146
157
|
const consumerBaseline = options.consumerBaseline ?? installed.consumerBaseline ?? baselineLabels[0] ?? `${packageData.pkg.name}@${packageData.pkg.version}`;
|
|
158
|
+
applyBootstrapMigrations(packageData, bootstrapMigrations, root);
|
|
147
159
|
applyProfile(packageData, plan, root, consumer, consumerBaseline);
|
|
148
160
|
console.log(`\nProject Forge ${command}: PASS (${packageData.pkg.name}@${packageData.pkg.version})`);
|
|
149
161
|
return 0;
|