phasegate 0.163.0 → 0.165.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/ADR/019-ai-independence-boundary.md +63 -0
  3. package/docs/ADR/020-reverse-learning-forward-proposal.md +52 -0
  4. package/package.json +1 -1
  5. package/scripts/harness/attestation/application/dto/attestation-document.ts +54 -0
  6. package/scripts/harness/attestation/application/dto/produce-attestation-input.ts +18 -0
  7. package/scripts/harness/attestation/application/dto/verify-attestation-input.ts +12 -0
  8. package/scripts/harness/attestation/application/dto/verify-attestation-output.ts +30 -0
  9. package/scripts/harness/attestation/application/mappers/attestation-record-mapper.ts +240 -0
  10. package/scripts/harness/attestation/application/ports/attestation-repository-port.ts +14 -0
  11. package/scripts/harness/attestation/application/ports/gate-result-source-port.ts +23 -0
  12. package/scripts/harness/attestation/application/ports/source-digester-port.ts +15 -0
  13. package/scripts/harness/attestation/application/usecases/produce-attestation-usecase.ts +155 -0
  14. package/scripts/harness/attestation/application/usecases/verify-attestation-usecase.ts +137 -0
  15. package/scripts/harness/attestation/composition-root.ts +93 -0
  16. package/scripts/harness/attestation/domain/entities/attestation-record.ts +240 -0
  17. package/scripts/harness/attestation/domain/ports/content-hasher-port.ts +13 -0
  18. package/scripts/harness/attestation/domain/services/granularity-derivation-service.ts +51 -0
  19. package/scripts/harness/attestation/domain/value-objects/digest.ts +51 -0
  20. package/scripts/harness/attestation/domain/value-objects/granularity-claim.ts +89 -0
  21. package/scripts/harness/attestation/domain/value-objects/signature-block.ts +87 -0
  22. package/scripts/harness/attestation/domain/value-objects/validator-outcome.ts +44 -0
  23. package/scripts/harness/attestation/index.ts +18 -0
  24. package/scripts/harness/attestation/infrastructure/adapters/ci-check-gate-result-adapter.ts +164 -0
  25. package/scripts/harness/attestation/infrastructure/adapters/file-system-attestation-repository-adapter.ts +32 -0
  26. package/scripts/harness/attestation/infrastructure/adapters/file-system-source-digester-adapter.ts +27 -0
  27. package/scripts/harness/attestation/infrastructure/adapters/node-crypto-content-hasher-adapter.ts +18 -0
  28. package/scripts/harness/attestation/presentation/handlers/attest-handler.ts +71 -0
  29. package/scripts/harness/attestation/presentation/handlers/verify-attestation-handler.ts +74 -0
  30. package/scripts/harness/main.ts +51 -2
@@ -0,0 +1,51 @@
1
+ // @unit attestation
2
+ // @layer domain
3
+
4
+ /**
5
+ * `sha256:<64 lowercase hex>` に適合しない digest を拒否する例外。
6
+ * errorCode: L1-050(横断決定事項 §3 / logical_design §2.4)
7
+ */
8
+ export class InvalidDigestError extends Error {
9
+ readonly errorCode = "L1-050";
10
+
11
+ constructor(raw: string) {
12
+ super(`Invalid digest: "${raw}". Must match sha256:<64 lowercase hex chars> [L1-050]`);
13
+ this.name = "InvalidDigestError";
14
+ Object.setPrototypeOf(this, new.target.prototype);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * content-addressed self-digest を表す値オブジェクト。
20
+ * attestation Unit がローカルに所有する(domain_model §1 所有判断 — cross-unit coupling 回避)。
21
+ * INV-7: すべての Digest は `sha256:` prefix + 64桁 hex に適合する。
22
+ */
23
+ export class Digest {
24
+ private static readonly PATTERN = /^sha256:[0-9a-f]{64}$/;
25
+
26
+ readonly value: string;
27
+
28
+ private constructor(value: string) {
29
+ this.value = value;
30
+ Object.freeze(this);
31
+ }
32
+
33
+ static create(raw: string): Digest {
34
+ if (!Digest.PATTERN.test(raw)) {
35
+ throw new InvalidDigestError(raw);
36
+ }
37
+ return new Digest(raw);
38
+ }
39
+
40
+ static fromSha256Hex(hex: string): Digest {
41
+ return Digest.create(`sha256:${hex}`);
42
+ }
43
+
44
+ equals(other: Digest): boolean {
45
+ return this.value === other.value;
46
+ }
47
+
48
+ toString(): string {
49
+ return this.value;
50
+ }
51
+ }
@@ -0,0 +1,89 @@
1
+ // @unit attestation
2
+ // @layer domain
3
+
4
+ export type GranularityLevel = "file" | "ac";
5
+
6
+ export interface GranularityClaimProps {
7
+ readonly validator: string;
8
+ readonly level: GranularityLevel;
9
+ readonly claim: string;
10
+ readonly knownLimitations: readonly string[];
11
+ }
12
+
13
+ /**
14
+ * validator set から機械導出される検査粒度の主張を表す値オブジェクト。
15
+ * L3-004 の file-level 制約(既知制約テキスト)を保持できる。
16
+ */
17
+ export class GranularityClaim {
18
+ readonly validator: string;
19
+ readonly level: GranularityLevel;
20
+ readonly claim: string;
21
+ readonly knownLimitations: readonly string[];
22
+
23
+ private constructor(props: GranularityClaimProps) {
24
+ this.validator = props.validator;
25
+ this.level = props.level;
26
+ this.claim = props.claim;
27
+ this.knownLimitations = Object.freeze([...props.knownLimitations]);
28
+ Object.freeze(this);
29
+ }
30
+
31
+ static create(props: GranularityClaimProps): GranularityClaim {
32
+ if (typeof props.validator !== "string" || props.validator.length === 0) {
33
+ throw new Error("GranularityClaim: validator must not be empty");
34
+ }
35
+ if (props.level !== "file" && props.level !== "ac") {
36
+ throw new Error(`GranularityClaim: level must be "file" or "ac", got: ${String(props.level)}`);
37
+ }
38
+ if (typeof props.claim !== "string") {
39
+ throw new Error("GranularityClaim: claim must be a string");
40
+ }
41
+ if (!Array.isArray(props.knownLimitations)) {
42
+ throw new Error("GranularityClaim: knownLimitations must be an array");
43
+ }
44
+ return new GranularityClaim(props);
45
+ }
46
+
47
+ equals(other: GranularityClaim): boolean {
48
+ return (
49
+ this.validator === other.validator &&
50
+ this.level === other.level &&
51
+ this.claim === other.claim &&
52
+ this.knownLimitations.length === other.knownLimitations.length &&
53
+ this.knownLimitations.every((v, i) => v === other.knownLimitations[i])
54
+ );
55
+ }
56
+ }
57
+
58
+ /**
59
+ * validatorId → 検査粒度の静的定義。anti-laundering の中核。
60
+ * 生成(H16-01)と検証(H16-02 再導出)で同一の粒度主張を返すため domain 定数として固定する。
61
+ */
62
+ export interface GranularityDefinition {
63
+ readonly validator: string;
64
+ readonly level: GranularityLevel;
65
+ readonly claim: string;
66
+ readonly knownLimitations: readonly string[];
67
+ }
68
+
69
+ export const L3_004_FILE_LEVEL_KNOWN_LIMITATION =
70
+ "L3-004 traceability is FILE-LEVEL, not per-AC — a green means each AC has >=1 referencing test FILE, " +
71
+ "not that each AC is individually asserted";
72
+
73
+ /**
74
+ * traceability 検査(L3-004)の静的粒度定義。
75
+ * L3-004 が validator set に含まれる場合、level "file" と file-level known-limitation を必ず付与する。
76
+ */
77
+ export const KNOWN_LIMITATIONS_REGISTRY: Readonly<Record<string, GranularityDefinition>> = Object.freeze({
78
+ "L3-004": Object.freeze({
79
+ validator: "L3-004",
80
+ level: "file",
81
+ claim:
82
+ "Traceability (L3-004) verifies that every acceptance criterion is referenced by at least one " +
83
+ "test file. This is a FILE-LEVEL guarantee.",
84
+ knownLimitations: Object.freeze([L3_004_FILE_LEVEL_KNOWN_LIMITATION]),
85
+ }),
86
+ });
87
+
88
+ /** traceability 検査に対応する validatorId。 */
89
+ export const TRACEABILITY_VALIDATOR_ID = "L3-004";
@@ -0,0 +1,87 @@
1
+ // @unit attestation
2
+ // @layer domain
3
+
4
+ import { Digest } from "./digest.js";
5
+
6
+ export type SignatureMode = "unsigned-poc" | "signed";
7
+
8
+ /**
9
+ * 未対応 signature mode(`signed`)の生成/検証要求で送出される例外。
10
+ * errorCode: L1-052(logical_design §2.4)
11
+ */
12
+ export class UnsupportedSignatureModeError extends Error {
13
+ readonly errorCode = "L1-052";
14
+
15
+ constructor(mode: string) {
16
+ super(`Unsupported signature mode: "${mode}" (not yet implemented) [L1-052]`);
17
+ this.name = "UnsupportedSignatureModeError";
18
+ Object.setPrototypeOf(this, new.target.prototype);
19
+ }
20
+ }
21
+
22
+ export interface SignatureBlockProps {
23
+ readonly mode: SignatureMode;
24
+ readonly attestationDigest: Digest;
25
+ readonly algorithm: string | null;
26
+ readonly keyId: string | null;
27
+ readonly value: string | null;
28
+ }
29
+
30
+ /**
31
+ * mode discriminator を持つ署名ブロック値オブジェクト。
32
+ * unsigned-poc は INTEGRITY のみを証明し、algorithm/keyId/value はすべて null(INV-6)。
33
+ */
34
+ export class SignatureBlock {
35
+ readonly mode: SignatureMode;
36
+ readonly attestationDigest: Digest;
37
+ readonly algorithm: string | null;
38
+ readonly keyId: string | null;
39
+ readonly value: string | null;
40
+
41
+ private constructor(props: SignatureBlockProps) {
42
+ this.mode = props.mode;
43
+ this.attestationDigest = props.attestationDigest;
44
+ this.algorithm = props.algorithm;
45
+ this.keyId = props.keyId;
46
+ this.value = props.value;
47
+ Object.freeze(this);
48
+ }
49
+
50
+ static create(props: SignatureBlockProps): SignatureBlock {
51
+ if (props.mode === "signed") {
52
+ throw new UnsupportedSignatureModeError("signed");
53
+ }
54
+ if (props.mode !== "unsigned-poc") {
55
+ throw new UnsupportedSignatureModeError(String(props.mode));
56
+ }
57
+ // INV-6: unsigned-poc のとき algorithm/keyId/value はすべて null
58
+ if (props.algorithm !== null || props.keyId !== null || props.value !== null) {
59
+ throw new UnsupportedSignatureModeError("unsigned-poc must have null algorithm/keyId/value");
60
+ }
61
+ if (!(props.attestationDigest instanceof Digest)) {
62
+ throw new Error("SignatureBlock: attestationDigest must be a Digest");
63
+ }
64
+ return new SignatureBlock(props);
65
+ }
66
+
67
+ /** unsigned-poc モードのブロックを構築する(algorithm/keyId/value を null で固定)。 */
68
+ static unsignedPoc(digest: Digest): SignatureBlock {
69
+ return new SignatureBlock({
70
+ mode: "unsigned-poc",
71
+ attestationDigest: digest,
72
+ algorithm: null,
73
+ keyId: null,
74
+ value: null,
75
+ });
76
+ }
77
+
78
+ equals(other: SignatureBlock): boolean {
79
+ return (
80
+ this.mode === other.mode &&
81
+ this.attestationDigest.equals(other.attestationDigest) &&
82
+ this.algorithm === other.algorithm &&
83
+ this.keyId === other.keyId &&
84
+ this.value === other.value
85
+ );
86
+ }
87
+ }
@@ -0,0 +1,44 @@
1
+ // @unit attestation
2
+ // @layer domain
3
+
4
+ export interface ValidatorOutcomeProps {
5
+ readonly validatorId: string;
6
+ readonly passed: boolean;
7
+ readonly skipped?: boolean;
8
+ }
9
+
10
+ /**
11
+ * ci-check の1バリデータ結果を写す値オブジェクト。
12
+ * `{ validatorId, passed, skipped }`。skipped 未指定は false に正規化する。
13
+ */
14
+ export class ValidatorOutcome {
15
+ readonly validatorId: string;
16
+ readonly passed: boolean;
17
+ readonly skipped: boolean;
18
+
19
+ private constructor(validatorId: string, passed: boolean, skipped: boolean) {
20
+ this.validatorId = validatorId;
21
+ this.passed = passed;
22
+ this.skipped = skipped;
23
+ Object.freeze(this);
24
+ }
25
+
26
+ static create(props: ValidatorOutcomeProps): ValidatorOutcome {
27
+ if (typeof props.validatorId !== "string" || props.validatorId.length === 0) {
28
+ throw new Error("ValidatorOutcome: validatorId must not be empty");
29
+ }
30
+ if (typeof props.passed !== "boolean") {
31
+ throw new Error("ValidatorOutcome: passed must be a boolean");
32
+ }
33
+ return new ValidatorOutcome(props.validatorId, props.passed, props.skipped === true);
34
+ }
35
+
36
+ /** allPassed 規則: passed または skipped ならグリーン。 */
37
+ isGreen(): boolean {
38
+ return this.passed || this.skipped;
39
+ }
40
+
41
+ equals(other: ValidatorOutcome): boolean {
42
+ return this.validatorId === other.validatorId && this.passed === other.passed && this.skipped === other.skipped;
43
+ }
44
+ }
@@ -0,0 +1,18 @@
1
+ // @layer infrastructure
2
+ // @unit attestation
3
+ // index.ts — attestation Unit の公開バレルエクスポート
4
+
5
+ // Public DTO types
6
+ export type { AttestationDocument } from "./application/dto/attestation-document.js";
7
+ export type { VerifyAttestationOutput } from "./application/dto/verify-attestation-output.js";
8
+ export type { AttestationModule, AttestationModuleOptions } from "./composition-root.js";
9
+ // Composition Root
10
+ export { createAttestationModule } from "./composition-root.js";
11
+ export type { AttestHandlerArgs, AttestHandlerResult } from "./presentation/handlers/attest-handler.js";
12
+ // Presentation Handlers
13
+ export { AttestHandler } from "./presentation/handlers/attest-handler.js";
14
+ export type {
15
+ VerifyAttestationHandlerArgs,
16
+ VerifyAttestationHandlerResult,
17
+ } from "./presentation/handlers/verify-attestation-handler.js";
18
+ export { VerifyAttestationHandler } from "./presentation/handlers/verify-attestation-handler.js";
@@ -0,0 +1,164 @@
1
+ // @unit attestation
2
+ // @layer infrastructure
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { dirname, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { GateResultSourcePort, GateValidatorResult } from "../../application/ports/gate-result-source-port.js";
8
+
9
+ interface CiCheckSubprocessResult {
10
+ readonly exitCode: number;
11
+ readonly stdout: string;
12
+ readonly stderr: string;
13
+ }
14
+
15
+ /**
16
+ * GateResultSourcePort の subprocess 実装(black-box observation)。
17
+ * `npx tsx <main.ts> phasegate:ci-check --json` を子プロセス実行し、
18
+ * stdout JSON の `data` から `{ allPassed, validatorResults[] }` を抽出する。
19
+ *
20
+ * ci-check の内部(harness-api)を import しない。gate fail でも ci-check は
21
+ * parseable JSON(allPassed:false)を出力するため、非0 exit は即エラーとせず
22
+ * stdout の JSON を解析する。JSON が解析不能/shape 不正のときのみ本物のクラッシュとして throw。
23
+ */
24
+ export class CiCheckGateResultAdapter implements GateResultSourcePort {
25
+ /** ci-check subprocess の最大待機時間(ms)。 */
26
+ private readonly timeoutMs: number;
27
+ /** テスト用のオーバーライド。既定は本 adapter が解決する main.ts。 */
28
+ private readonly mainTsPath: string;
29
+
30
+ constructor(options?: { readonly timeoutMs?: number; readonly mainTsPath?: string }) {
31
+ this.timeoutMs = options?.timeoutMs ?? 300_000;
32
+ this.mainTsPath = options?.mainTsPath ?? resolveMainTsPath();
33
+ }
34
+
35
+ async fetchGateResult(): Promise<{
36
+ readonly allPassed: boolean;
37
+ readonly validatorResults: readonly GateValidatorResult[];
38
+ }> {
39
+ const result = await this.runCiCheck();
40
+ const parsed = this.parseResponse(result);
41
+ return parsed;
42
+ }
43
+
44
+ private parseResponse(result: CiCheckSubprocessResult): {
45
+ allPassed: boolean;
46
+ validatorResults: GateValidatorResult[];
47
+ } {
48
+ const json = extractJsonObject(result.stdout);
49
+ if (json === null) {
50
+ throw new Error(
51
+ `ci-check produced no parseable JSON (exit ${result.exitCode}). stderr: ${result.stderr.slice(0, 500)}`,
52
+ );
53
+ }
54
+ const data = (json as Record<string, unknown>).data;
55
+ if (typeof data !== "object" || data === null) {
56
+ throw new Error(`ci-check JSON has no "data" field (exit ${result.exitCode})`);
57
+ }
58
+ const dataRecord = data as Record<string, unknown>;
59
+ const rawValidators = dataRecord.validatorResults;
60
+ if (!Array.isArray(rawValidators)) {
61
+ throw new Error('ci-check JSON "data.validatorResults" is not an array');
62
+ }
63
+ if (typeof dataRecord.allPassed !== "boolean") {
64
+ throw new Error('ci-check JSON "data.allPassed" is not a boolean');
65
+ }
66
+ const validatorResults: GateValidatorResult[] = rawValidators.map((raw, i) => {
67
+ if (typeof raw !== "object" || raw === null) {
68
+ throw new Error(`ci-check validatorResults[${i}] is not an object`);
69
+ }
70
+ const item = raw as Record<string, unknown>;
71
+ if (typeof item.validatorId !== "string") {
72
+ throw new Error(`ci-check validatorResults[${i}].validatorId is not a string`);
73
+ }
74
+ if (typeof item.passed !== "boolean") {
75
+ throw new Error(`ci-check validatorResults[${i}].passed is not a boolean`);
76
+ }
77
+ return {
78
+ validatorId: item.validatorId,
79
+ passed: item.passed,
80
+ skipped: typeof item.skipped === "boolean" ? item.skipped : false,
81
+ };
82
+ });
83
+ return { allPassed: dataRecord.allPassed, validatorResults };
84
+ }
85
+
86
+ private runCiCheck(): Promise<CiCheckSubprocessResult> {
87
+ return new Promise((resolvePromise, reject) => {
88
+ const child = spawn("npx", ["tsx", this.mainTsPath, "phasegate:ci-check", "--json"], {
89
+ stdio: ["pipe", "pipe", "pipe"],
90
+ shell: false,
91
+ env: process.env,
92
+ });
93
+
94
+ let stdout = "";
95
+ let stderr = "";
96
+ let timedOut = false;
97
+
98
+ const timer = setTimeout(() => {
99
+ timedOut = true;
100
+ child.kill("SIGTERM");
101
+ reject(new Error(`ci-check subprocess timed out after ${this.timeoutMs}ms`));
102
+ }, this.timeoutMs);
103
+
104
+ child.stdout?.on("data", (chunk: Buffer) => {
105
+ stdout += chunk.toString();
106
+ });
107
+ child.stderr?.on("data", (chunk: Buffer) => {
108
+ stderr += chunk.toString();
109
+ });
110
+ child.on("error", (error) => {
111
+ clearTimeout(timer);
112
+ if (!timedOut) reject(error);
113
+ });
114
+ child.on("close", (code) => {
115
+ clearTimeout(timer);
116
+ if (timedOut) return;
117
+ resolvePromise({ exitCode: code ?? 0, stdout, stderr });
118
+ });
119
+ child.stdin?.end();
120
+ });
121
+ }
122
+ }
123
+
124
+ function resolveMainTsPath(): string {
125
+ // infrastructure/adapters/ から 3 階層上が scripts/harness/。
126
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../../../main.ts");
127
+ }
128
+
129
+ /**
130
+ * stdout から最初の JSON オブジェクトを抽出する。ci-check は他ログを stdout に混ぜ得るため、
131
+ * 各行を試し、失敗時は最初の `{` から最後の `}` までを試す。
132
+ */
133
+ function extractJsonObject(stdout: string): unknown {
134
+ const trimmed = stdout.trim();
135
+ if (trimmed.length === 0) return null;
136
+
137
+ const direct = tryParse(trimmed);
138
+ if (direct !== undefined) return direct;
139
+
140
+ const lines = trimmed.split("\n");
141
+ for (const line of lines) {
142
+ const candidate = line.trim();
143
+ if (candidate.startsWith("{") && candidate.endsWith("}")) {
144
+ const parsed = tryParse(candidate);
145
+ if (parsed !== undefined) return parsed;
146
+ }
147
+ }
148
+
149
+ const first = trimmed.indexOf("{");
150
+ const last = trimmed.lastIndexOf("}");
151
+ if (first !== -1 && last > first) {
152
+ const parsed = tryParse(trimmed.slice(first, last + 1));
153
+ if (parsed !== undefined) return parsed;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ function tryParse(text: string): unknown {
159
+ try {
160
+ return JSON.parse(text);
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
@@ -0,0 +1,32 @@
1
+ // @unit attestation
2
+ // @layer infrastructure
3
+
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { dirname, isAbsolute, resolve } from "node:path";
6
+ import type { AttestationDocument } from "../../application/dto/attestation-document.js";
7
+ import type { AttestationRepositoryPort } from "../../application/ports/attestation-repository-port.js";
8
+
9
+ /**
10
+ * AttestationRepositoryPort の node:fs 実装。
11
+ * write は親ディレクトリを作成し、2スペース整形 JSON + 改行で書き出す。
12
+ * read は readFile + JSON.parse。不在/parse 失敗はそのまま throw し usecase が exitCode 2 へ変換する。
13
+ */
14
+ export class FileSystemAttestationRepositoryAdapter implements AttestationRepositoryPort {
15
+ constructor(private readonly baseDir: string) {}
16
+
17
+ async write(path: string, doc: AttestationDocument): Promise<void> {
18
+ const absPath = this.resolvePath(path);
19
+ await mkdir(dirname(absPath), { recursive: true });
20
+ await writeFile(absPath, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
21
+ }
22
+
23
+ async read(path: string): Promise<unknown> {
24
+ const absPath = this.resolvePath(path);
25
+ const raw = await readFile(absPath, "utf8");
26
+ return JSON.parse(raw);
27
+ }
28
+
29
+ private resolvePath(path: string): string {
30
+ return isAbsolute(path) ? path : resolve(this.baseDir, path);
31
+ }
32
+ }
@@ -0,0 +1,27 @@
1
+ // @unit attestation
2
+ // @layer infrastructure
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import { isAbsolute, resolve } from "node:path";
7
+ import type { SourceDigesterPort } from "../../application/ports/source-digester-port.js";
8
+ import { Digest } from "../../domain/value-objects/digest.js";
9
+
10
+ /**
11
+ * SourceDigesterPort の node:fs 実装。
12
+ * source パスの「現在の」内容を読み、sha256 Digest を返す。
13
+ * ci-governance の file-system-sha1-hasher-adapter をミラーするが、アルゴリズムは sha256。
14
+ *
15
+ * 不在ファイルは readFile が throw する。verify 時は usecase が本 throw を捕捉し
16
+ * inputHashes チェックの失敗(mismatch)として扱う(クラッシュではない)。
17
+ */
18
+ export class FileSystemSourceDigesterAdapter implements SourceDigesterPort {
19
+ constructor(private readonly baseDir: string) {}
20
+
21
+ async digestFile(path: string): Promise<Digest> {
22
+ const absPath = isAbsolute(path) ? path : resolve(this.baseDir, path);
23
+ const content = await readFile(absPath);
24
+ const hex = createHash("sha256").update(content).digest("hex");
25
+ return Digest.fromSha256Hex(hex);
26
+ }
27
+ }
@@ -0,0 +1,18 @@
1
+ // @unit attestation
2
+ // @layer infrastructure
3
+
4
+ import { createHash } from "node:crypto";
5
+ import type { ContentHasherPort } from "../../domain/ports/content-hasher-port.js";
6
+ import { Digest } from "../../domain/value-objects/digest.js";
7
+
8
+ /**
9
+ * ContentHasherPort の node:crypto 実装。
10
+ * canonical payload / source content の sha256 を `sha256:<64hex>` Digest として返す。
11
+ * installation の node-crypto-hash-adapter をミラーし、アルゴリズムは sha256 に固定する。
12
+ */
13
+ export class NodeCryptoContentHasherAdapter implements ContentHasherPort {
14
+ sha256(content: string): Digest {
15
+ const hex = createHash("sha256").update(content, "utf8").digest("hex");
16
+ return Digest.fromSha256Hex(hex);
17
+ }
18
+ }
@@ -0,0 +1,71 @@
1
+ // @unit attestation
2
+ // @layer presentation
3
+
4
+ import type { ProduceAttestationInput } from "../../application/dto/produce-attestation-input.js";
5
+ import type { ProduceAttestationUseCase } from "../../application/usecases/produce-attestation-usecase.js";
6
+ import type { SignatureMode } from "../../domain/value-objects/signature-block.js";
7
+
8
+ export interface AttestHandlerArgs {
9
+ /** record 出力先。既定は presentation 呼び出し側で `.harness/attestation.json`。 */
10
+ readonly out?: string;
11
+ readonly requirePass?: boolean;
12
+ readonly emitJson?: boolean;
13
+ /** 署名モード(既定 unsigned-poc)。`signed` は not-yet-implemented。 */
14
+ readonly mode?: string;
15
+ }
16
+
17
+ export interface AttestHandlerResult {
18
+ readonly output: string;
19
+ readonly exitCode: 0 | 1 | 2;
20
+ }
21
+
22
+ const DEFAULT_OUT = ".harness/attestation.json";
23
+
24
+ /**
25
+ * H16-01: phasegate:attest の CLI ハンドラ。
26
+ * flags を ProduceAttestationInput に解釈し UseCase を呼び、output + exitCode に整形する。
27
+ * 終了コード: 0 成功 / 1 --require-pass 下で gate fail / 2 usage error(未知 mode・signed)。
28
+ */
29
+ export class AttestHandler {
30
+ constructor(private readonly useCase: ProduceAttestationUseCase) {}
31
+
32
+ async handle(args: AttestHandlerArgs): Promise<AttestHandlerResult> {
33
+ const rawMode = args.mode ?? "unsigned-poc";
34
+ if (rawMode !== "unsigned-poc" && rawMode !== "signed") {
35
+ return { output: `Error: unknown --mode "${rawMode}" (expected unsigned-poc | signed)`, exitCode: 2 };
36
+ }
37
+ const mode: SignatureMode = rawMode;
38
+
39
+ const input: ProduceAttestationInput = {
40
+ out: args.out && args.out.length > 0 ? args.out : DEFAULT_OUT,
41
+ requirePass: args.requirePass === true,
42
+ emitJson: args.emitJson === true,
43
+ mode,
44
+ };
45
+
46
+ const result = await this.useCase.execute(input);
47
+
48
+ if (result.exitCode === 2) {
49
+ // 唯一の usecase 由来 2 は mode === "signed"。
50
+ return { output: 'Error: --mode "signed" is not yet implemented (only unsigned-poc is supported)', exitCode: 2 };
51
+ }
52
+
53
+ if (result.exitCode === 1) {
54
+ return {
55
+ output: 'Gate result is not "pass"; --require-pass suppressed attestation output.',
56
+ exitCode: 1,
57
+ };
58
+ }
59
+
60
+ // exitCode 0: 生成成功
61
+ if (result.document === null) {
62
+ // 型上は起こり得ないが安全側で扱う。
63
+ return { output: "Error: attestation produced no document.", exitCode: 2 };
64
+ }
65
+
66
+ if (input.emitJson) {
67
+ return { output: JSON.stringify(result.document, null, 2), exitCode: 0 };
68
+ }
69
+ return { output: `Attestation written to ${input.out}`, exitCode: 0 };
70
+ }
71
+ }
@@ -0,0 +1,74 @@
1
+ // @unit attestation
2
+ // @layer presentation
3
+
4
+ import type { VerifyAttestationInput } from "../../application/dto/verify-attestation-input.js";
5
+ import type { VerifyAttestationOutput } from "../../application/dto/verify-attestation-output.js";
6
+ import type { VerifyAttestationUseCase } from "../../application/usecases/verify-attestation-usecase.js";
7
+
8
+ export interface VerifyAttestationHandlerArgs {
9
+ /** 検証対象 attestation ファイルパス(位置引数)。 */
10
+ readonly file?: string;
11
+ readonly emitJson?: boolean;
12
+ }
13
+
14
+ export interface VerifyAttestationHandlerResult {
15
+ readonly output: string;
16
+ readonly exitCode: 0 | 1 | 2;
17
+ }
18
+
19
+ /**
20
+ * H16-02: phasegate:verify-attestation のハンドラ。
21
+ * 位置引数 <file> + --json を解釈し UseCase を呼び、機械的 5 チェックを整形する。
22
+ * 終了コード: 0 全合格 / 1 mismatch / 2 不在・malformed・非対応 mode。
23
+ */
24
+ export class VerifyAttestationHandler {
25
+ constructor(private readonly useCase: VerifyAttestationUseCase) {}
26
+
27
+ async handle(args: VerifyAttestationHandlerArgs): Promise<VerifyAttestationHandlerResult> {
28
+ if (!args.file || args.file.trim().length === 0) {
29
+ return { output: "Error: <file> is required. Usage: phasegate:verify-attestation <file> [--json]", exitCode: 2 };
30
+ }
31
+
32
+ const input: VerifyAttestationInput = {
33
+ filePath: args.file,
34
+ emitJson: args.emitJson === true,
35
+ };
36
+
37
+ const result = await this.useCase.execute(input);
38
+
39
+ if (input.emitJson) {
40
+ return { output: JSON.stringify(result.output, null, 2), exitCode: result.exitCode };
41
+ }
42
+ return { output: renderHuman(result.output, result.exitCode), exitCode: result.exitCode };
43
+ }
44
+ }
45
+
46
+ function mark(ok: boolean): string {
47
+ return ok ? "PASS" : "FAIL";
48
+ }
49
+
50
+ function renderHuman(output: VerifyAttestationOutput, exitCode: 0 | 1 | 2): string {
51
+ const c = output.checks;
52
+ const lines: string[] = [];
53
+ lines.push(`schema : ${mark(c.schema)}`);
54
+ lines.push(`mode : ${mark(c.mode)}`);
55
+ lines.push(`attestationDigest: ${mark(c.attestationDigest)}`);
56
+ lines.push(`inputHashes : ${mark(c.inputHashes)}`);
57
+ lines.push(`granularity : ${mark(c.granularity)}`);
58
+ if (output.mismatches.length > 0) {
59
+ lines.push("");
60
+ lines.push("Mismatches:");
61
+ for (const m of output.mismatches) {
62
+ lines.push(` - ${m}`);
63
+ }
64
+ }
65
+ lines.push("");
66
+ if (exitCode === 0) {
67
+ lines.push("Result: OK (all checks passed)");
68
+ } else if (exitCode === 1) {
69
+ lines.push("Result: MISMATCH (attestation integrity check failed)");
70
+ } else {
71
+ lines.push("Result: ERROR (missing, malformed, or unsupported attestation)");
72
+ }
73
+ return lines.join("\n");
74
+ }