yam-flow 2.8.0 → 2.8.2

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.
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+
8
+ const MAX_BYTES = 256 * 1024;
9
+ const sha = (text: string) => createHash('sha256').update(text).digest('hex');
10
+
11
+ /** Select an upgrade-stable alias only when it resolves to the running Node binary. */
12
+ export async function selectHookNode(executable = process.execPath, searchPath = process.env.PATH || '') {
13
+ const real = await fsp.realpath(executable);
14
+ const running = await fsp.stat(real);
15
+ const candidates = searchPath.split(path.delimiter).filter((dir) => path.isAbsolute(dir))
16
+ .map((dir) => path.join(dir, process.platform === 'win32' ? 'node.exe' : 'node'));
17
+ for (const candidate of candidates) {
18
+ try {
19
+ const canonical = await fsp.realpath(candidate);
20
+ const stat = await fsp.stat(canonical);
21
+ await fsp.access(candidate, fs.constants.X_OK);
22
+ if (stat.isFile() && stat.dev === running.dev && stat.ino === running.ino && canonical === real) {
23
+ return { executable: candidate, canonical, selection: 'verified_runtime_alias', dev: stat.dev, ino: stat.ino };
24
+ }
25
+ } catch { /* An unverified PATH candidate is never executed. */ }
26
+ }
27
+ return { executable: real, canonical: real, selection: 'current_runtime_fallback', dev: running.dev, ino: running.ino };
28
+ }
29
+
30
+ export function summarizeHookDiscovery(value: unknown, cwd: string, commands: Record<string, string[]>, configPath?: string) {
31
+ const input = value as { data?: Array<{ cwd?: string; hooks?: Array<Record<string, unknown>>; errors?: unknown[]; warnings?: unknown[] }> };
32
+ if (!Array.isArray(input?.data)) return { state: 'unknown', reason: 'unsupported_response_schema', profiles: {} };
33
+ const entry = input.data.find((row) => row.cwd === cwd);
34
+ if (!entry || !Array.isArray(entry.hooks) || !Array.isArray(entry.errors) || !Array.isArray(entry.warnings)) {
35
+ return { state: 'unknown', reason: 'missing_cwd_or_unsupported_response', profiles: {} };
36
+ }
37
+ const profiles = Object.fromEntries(Object.entries(commands).map(([profile, expected]) => {
38
+ const matches = entry.hooks.filter((hook) => hook.handlerType === 'command' && expected.includes(String(hook.command))
39
+ && (!configPath || hook.sourcePath === configPath));
40
+ const approved = matches.filter((hook) => hook.enabled === true && ['trusted', 'managed'].includes(String(hook.trustStatus)));
41
+ const expectedEvents = profile === 'study-note' ? ['userPromptSubmit', 'stop'] : ['userPromptSubmit'];
42
+ return [profile, { state: matches.length ? 'discovered' : 'not_discovered', enabled_trusted: approved.length,
43
+ eligible_event_coverage: expectedEvents.every((event) => approved.filter((hook) => hook.eventName === event).length === 1),
44
+ discovered_count: matches.length, trust_statuses: [...new Set(matches.map((hook) => String(hook.trustStatus)))],
45
+ execution_observed: false }];
46
+ }));
47
+ return { state: entry.errors.length ? 'partial' : 'queried', reason: entry.errors.length ? 'host_discovery_errors' : '',
48
+ profiles, warning_count: entry.warnings.length, error_count: entry.errors.length };
49
+ }
50
+
51
+ /** Opt-in discovery in a new app-server process, not the active desktop session. */
52
+ export async function probeHookDiscovery(options: { executable: string; cwd: string; commands: Record<string, string[]>; configPath?: string; timeoutMs?: number }) {
53
+ const timeoutMs = Math.min(Math.max(options.timeoutMs || 5000, 100), 10000);
54
+ return new Promise<Record<string, unknown>>((resolve) => {
55
+ let finished = false;
56
+ let stopping = false;
57
+ let failure = '';
58
+ let result: Record<string, unknown>;
59
+ let buffer = '';
60
+ let total = 0;
61
+ let timer: NodeJS.Timeout;
62
+ let killTimer: NodeJS.Timeout;
63
+ const child = spawn(options.executable, ['app-server'], { cwd: options.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false });
64
+ const stop = (reason = '') => {
65
+ if (stopping) return;
66
+ stopping = true;
67
+ failure = reason;
68
+ child.stdin.end();
69
+ child.kill('SIGTERM');
70
+ killTimer = setTimeout(() => child.kill('SIGKILL'), 500);
71
+ };
72
+ const send = (value: unknown) => { if (!stopping) child.stdin.write(`${JSON.stringify(value)}\n`); };
73
+ child.stdin.on('error', () => stop('protocol_stdin_failed'));
74
+ child.on('error', () => { failure = 'host_unavailable'; });
75
+ child.stderr.on('data', (chunk) => { total += chunk.length; if (total > MAX_BYTES) stop('output_limit'); });
76
+ child.stdout.on('data', (chunk) => {
77
+ total += chunk.length;
78
+ if (total > MAX_BYTES) return stop('output_limit');
79
+ buffer += chunk.toString('utf8');
80
+ let index: number;
81
+ while (!stopping && (index = buffer.indexOf('\n')) >= 0) {
82
+ const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
83
+ let message;
84
+ try { message = JSON.parse(line); } catch { stop('invalid_protocol_json'); break; }
85
+ if (message.id === 0) {
86
+ if (message.error) { stop('initialization_rejected'); break; }
87
+ send({ method: 'initialized', params: {} });
88
+ send({ method: 'hooks/list', id: 1, params: { cwds: [options.cwd] } });
89
+ } else if (message.id === 1) {
90
+ if (message.error) { stop('hooks_list_unsupported_or_rejected'); break; }
91
+ result = summarizeHookDiscovery(message.result, options.cwd, options.commands, options.configPath);
92
+ stop();
93
+ } else if (message.id != null && message.method) {
94
+ stop('unexpected_server_request');
95
+ }
96
+ }
97
+ });
98
+ child.on('close', (_code, signal) => {
99
+ if (finished) return;
100
+ finished = true;
101
+ clearTimeout(timer); clearTimeout(killTimer);
102
+ resolve({ ...(result || { state: 'unknown', profiles: {} }), ...(failure ? { state: 'unknown', reason: failure } : {}),
103
+ host_context: 'separate_app_server_probe', active_session_loaded: 'not_measured',
104
+ execution_observed: false, process_exit_observed: true, termination_signal: signal || null,
105
+ truth_status: 'partial', reason: failure || (result ? result.reason : 'host_exited_before_response') });
106
+ });
107
+ timer = setTimeout(() => stop('probe_timeout'), timeoutMs);
108
+ send({ method: 'initialize', id: 0, params: { clientInfo: { name: 'yam_hook_probe', version: '1' }, capabilities: { experimentalApi: true } } });
109
+ });
110
+ }
111
+
112
+ async function safeObservationDirectory(cwd: string, create: boolean) {
113
+ const root = await fsp.realpath(cwd);
114
+ let current = root;
115
+ const identities: Array<{ target: string; dev: number; ino: number }> = [];
116
+ for (const part of ['', '.yam', 'hooks']) {
117
+ if (part) current = path.join(current, part);
118
+ if (create && part) await fsp.mkdir(current, { mode: 0o700 }).catch((error) => { if (error.code !== 'EEXIST') throw error; });
119
+ const stat = await fsp.lstat(current);
120
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe_observation_directory');
121
+ identities.push({ target: current, dev: stat.dev, ino: stat.ino });
122
+ }
123
+ return { directory: current, identities };
124
+ }
125
+
126
+ async function checkParents(identities: Array<{ target: string; dev: number; ino: number }>) {
127
+ for (const identity of identities) {
128
+ const stat = await fsp.lstat(identity.target);
129
+ if (stat.isSymbolicLink() || !stat.isDirectory() || stat.dev !== identity.dev || stat.ino !== identity.ino) throw new Error('observation_parent_changed');
130
+ }
131
+ }
132
+
133
+ async function configurationDigest(cwd: string) {
134
+ const paths = [path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json'), path.join(await fsp.realpath(cwd), '.codex', 'hooks.json')];
135
+ const values = [];
136
+ for (const target of paths) {
137
+ try {
138
+ const before = await fsp.lstat(target);
139
+ if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_BYTES) throw new Error('unsafe_hook_configuration');
140
+ const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
141
+ try {
142
+ const stat = await handle.stat();
143
+ if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > MAX_BYTES) throw new Error('hook_configuration_changed');
144
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
145
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
146
+ if (bytesRead > MAX_BYTES) throw new Error('hook_configuration_limit');
147
+ values.push(sha(buffer.subarray(0, bytesRead).toString('utf8')));
148
+ } finally { await handle.close(); }
149
+ } catch (error) {
150
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
151
+ values.push('absent');
152
+ }
153
+ }
154
+ return sha(JSON.stringify(values));
155
+ }
156
+
157
+ export async function recordHookObservation(cwd: string, profile: string, event: string, entrypoint: string) {
158
+ if (!['lite', 'study-note'].includes(profile) || !['UserPromptSubmit', 'Stop'].includes(event)) throw new Error('unsupported_observation');
159
+ const { directory, identities } = await safeObservationDirectory(cwd, true);
160
+ const value = { schema: 'yam.hook-observation.v1', profile, event, observed_at: new Date().toISOString(),
161
+ cwd_digest: sha(await fsp.realpath(cwd)), entrypoint_digest: sha(await fsp.readFile(entrypoint, 'utf8')),
162
+ configuration_digest: await configurationDigest(cwd),
163
+ host_authenticated: false, evidence_kind: 'local_entrypoint_invocation' };
164
+ const target = path.join(directory, `${profile}.json`);
165
+ const noFollow = process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0;
166
+ const before = await fsp.lstat(target).catch((error) => { if (error.code === 'ENOENT') return null; throw error; });
167
+ if (before && (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1)) throw new Error('unsafe_observation_file');
168
+ const handle = await fsp.open(target, fs.constants.O_WRONLY | noFollow | (before ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL), 0o600);
169
+ try {
170
+ const stat = await handle.stat();
171
+ const pathname = await fsp.lstat(target);
172
+ if (!stat.isFile() || stat.nlink !== 1 || pathname.isSymbolicLink() || pathname.dev !== stat.dev || pathname.ino !== stat.ino
173
+ || (before && (before.dev !== stat.dev || before.ino !== stat.ino))) throw new Error('unsafe_observation_file');
174
+ await checkParents(identities);
175
+ await handle.chmod(0o600);
176
+ await handle.truncate(0);
177
+ await handle.writeFile(`${JSON.stringify(value)}\n`);
178
+ await checkParents(identities);
179
+ const after = await fsp.lstat(target);
180
+ if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino) throw new Error('observation_identity_changed');
181
+ } finally { await handle.close(); }
182
+ }
183
+
184
+ export async function inspectHookObservation(cwd: string, profile: string, entrypoint: string) {
185
+ try {
186
+ if (!['lite', 'study-note'].includes(profile)) throw new Error('unsupported_profile');
187
+ const { directory, identities } = await safeObservationDirectory(cwd, false);
188
+ const target = path.join(directory, `${profile}.json`);
189
+ const before = await fsp.lstat(target);
190
+ if (!before.isFile() || before.isSymbolicLink() || before.size > 4096 || before.nlink !== 1) throw new Error('unsafe_observation_file');
191
+ const handle = await fsp.open(target, fs.constants.O_RDONLY | (process.platform === 'win32' ? 0 : fs.constants.O_NOFOLLOW || 0));
192
+ let value;
193
+ try {
194
+ const stat = await handle.stat();
195
+ if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096) throw new Error('observation_identity_changed');
196
+ const data = Buffer.alloc(4097); const { bytesRead } = await handle.read(data, 0, data.length, 0);
197
+ if (bytesRead > 4096) throw new Error('observation_limit');
198
+ value = JSON.parse(data.subarray(0, bytesRead).toString('utf8'));
199
+ await checkParents(identities);
200
+ } finally { await handle.close(); }
201
+ const age = Date.now() - Date.parse(value.observed_at);
202
+ if (value.schema !== 'yam.hook-observation.v1' || value.profile !== profile || !['UserPromptSubmit', 'Stop'].includes(value.event)
203
+ || value.host_authenticated !== false || value.evidence_kind !== 'local_entrypoint_invocation'
204
+ || !Number.isFinite(age) || age < 0 || age > 24 * 60 * 60 * 1000
205
+ || value.cwd_digest !== sha(await fsp.realpath(cwd)) || value.configuration_digest !== await configurationDigest(cwd)
206
+ || value.entrypoint_digest !== sha(await fsp.readFile(entrypoint, 'utf8'))) {
207
+ return { state: 'unknown', reason: 'stale_or_mismatched_observation', truth_status: 'partial', host_authenticated: false };
208
+ }
209
+ return { state: 'entrypoint_observed', observed_at: value.observed_at, event: value.event,
210
+ host_authenticated: false, truth_status: 'partial', reason: 'local_same_user_evidence_not_host_attestation' };
211
+ } catch (error) {
212
+ return { state: 'not_observed', reason: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'no_observation' : 'observation_unreadable_or_unsafe',
213
+ host_authenticated: false, truth_status: 'partial' };
214
+ }
215
+ }
@@ -0,0 +1,109 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export type MemoryReadState = 'missing' | 'empty' | 'complete' | 'unreadable' | 'invalid' | 'incomplete';
5
+ export interface MemoryRecord {
6
+ schemaVersion: number;
7
+ id: string;
8
+ kind: string;
9
+ status: string;
10
+ summary: string;
11
+ evidence?: string;
12
+ action?: string;
13
+ source?: string;
14
+ createdAt?: string;
15
+ [key: string]: unknown;
16
+ }
17
+ export interface MemoryReadReport {
18
+ schema: 'yam.memory-read.v1';
19
+ state: MemoryReadState;
20
+ complete: boolean;
21
+ records: MemoryRecord[];
22
+ diagnostics: Array<{ operation: string; code: string; record_index?: number }>;
23
+ diagnostics_truncated: boolean;
24
+ }
25
+ export type MemoryReadIo = Pick<typeof fsp, 'access' | 'readdir' | 'readFile'>;
26
+ const kinds = new Set(['wrong_decision', 'repeat_mistake', 'direction_change', 'lesson', 'risk', 'command']);
27
+ const safeCodes = new Set(['ENOENT', 'EACCES', 'EPERM', 'EIO', 'ENOTDIR', 'EISDIR', 'EMFILE', 'ENFILE', 'ELOOP']);
28
+
29
+ function errorCode(error: unknown): string {
30
+ const code = (error as { code?: unknown })?.code;
31
+ return typeof code === 'string' && safeCodes.has(code) ? code : 'READ_FAILED';
32
+ }
33
+
34
+ function isMemoryRecord(value: unknown): value is MemoryRecord {
35
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
36
+ const r = value as Record<string, unknown>;
37
+ return r.schemaVersion === 1 && typeof r.id === 'string' && r.id.length > 0
38
+ && typeof r.kind === 'string' && kinds.has(r.kind)
39
+ && (r.status === 'active' || r.status === 'resolved')
40
+ && typeof r.summary === 'string' && r.summary.trim().length > 0
41
+ && ['evidence', 'action', 'source', 'createdAt', 'resolvedAt', 'resolution'].every((key) => r[key] === undefined || typeof r[key] === 'string');
42
+ }
43
+
44
+ /** Diagnostics intentionally contain no filesystem paths, record text, or exception messages. */
45
+ export async function readMemoryDirectory(recordsDir: string, io: MemoryReadIo = fsp): Promise<MemoryReadReport> {
46
+ const report: MemoryReadReport = {
47
+ schema: 'yam.memory-read.v1', state: 'complete', complete: true,
48
+ records: [], diagnostics: [], diagnostics_truncated: false
49
+ };
50
+ const diagnose = (operation: string, code: string, record_index?: number) => {
51
+ if (report.diagnostics.length < 20) report.diagnostics.push({ operation, code, ...(record_index === undefined ? {} : { record_index }) });
52
+ else report.diagnostics_truncated = true;
53
+ };
54
+ try {
55
+ await io.access(recordsDir);
56
+ } catch (error) {
57
+ const code = errorCode(error);
58
+ report.state = code === 'ENOENT' ? 'missing' : 'unreadable';
59
+ report.complete = false;
60
+ diagnose('access', code);
61
+ return report;
62
+ }
63
+ let entries;
64
+ try {
65
+ entries = await io.readdir(recordsDir, { withFileTypes: true });
66
+ } catch (error) {
67
+ // An existing directory disappearing mid-read is incomplete, not an empty memory.
68
+ report.state = errorCode(error) === 'ENOENT' ? 'incomplete' : 'unreadable';
69
+ report.complete = false;
70
+ diagnose('readdir', errorCode(error));
71
+ return report;
72
+ }
73
+ let invalid = 0;
74
+ let unreadable = 0;
75
+ const candidates = entries.filter((entry) => entry.name.endsWith('.json')).sort((a, b) => a.name.localeCompare(b.name));
76
+ for (const [index, entry] of candidates.entries()) {
77
+ if (!entry.isFile()) {
78
+ unreadable += 1;
79
+ diagnose('record_type', 'NOT_REGULAR_FILE', index);
80
+ continue;
81
+ }
82
+ let text: string;
83
+ try {
84
+ text = await io.readFile(path.join(recordsDir, entry.name), 'utf8');
85
+ } catch (error) {
86
+ unreadable += 1;
87
+ diagnose('read', errorCode(error), index);
88
+ continue;
89
+ }
90
+ let record: unknown;
91
+ try { record = JSON.parse(text); } catch {
92
+ invalid += 1;
93
+ diagnose('parse', 'INVALID_JSON', index);
94
+ continue;
95
+ }
96
+ if (!isMemoryRecord(record)) {
97
+ invalid += 1;
98
+ diagnose('validate', 'INVALID_RECORD', index);
99
+ continue;
100
+ }
101
+ report.records.push(record);
102
+ }
103
+ report.records.sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')));
104
+ report.complete = invalid + unreadable === 0;
105
+ report.state = report.complete ? (report.records.length ? 'complete' : 'empty')
106
+ : report.records.length || (invalid && unreadable) ? 'incomplete'
107
+ : unreadable ? 'unreadable' : 'invalid';
108
+ return report;
109
+ }
@@ -0,0 +1,184 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fsp from 'node:fs/promises';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export const SECURITY_STATUSES = ['passed', 'needs_improvement', 'failed', 'unverified', 'not_applicable'] as const;
7
+ export type SecurityStatus = typeof SECURITY_STATUSES[number];
8
+ export type SecurityStage = 'commit' | 'push' | 'deploy';
9
+ export const SEC_AREAS = {
10
+ 'SEC-01': '사용자·관리자·외부 서비스·DB·파일 사이의 신뢰 경계와 보호할 데이터·작업을 식별한다.',
11
+ 'SEC-02': '명시적으로 허용한 접근만 열고, 사용자·서비스 계정에 필요한 최소 권한만 부여한다.',
12
+ 'SEC-03': '화면을 우회한 API·서버 함수·DB·파일·백그라운드 작업에서도 사용자·조직·대상별 권한을 확인한다.',
13
+ 'SEC-04': '계정 정지·승인 철회·탈퇴·소속 변경 후 기존 세션·토큰·캐시에서도 권한 회수가 반영된다.',
14
+ 'SEC-05': '코드의 보안 정책·migration·ACL·환경 설정이 대상 배포 환경의 실제 상태와 일치한다.',
15
+ 'SEC-06': '로그인·관리자 인증·세션 만료·로그아웃·비밀번호 재설정·계정 복구가 계정 탈취를 막도록 동작한다.',
16
+ 'SEC-07': '외부 입력·파일 업로드·출력의 형식·크기·경로를 검증하고 SQL 주입·XSS·SSRF·CSRF 등 해당 공격을 방어한다.',
17
+ 'SEC-08': 'API 키·토큰·개인키·개인정보가 코드·브라우저 번들·로그·점검 기록에 불필요하게 노출되지 않는다.',
18
+ 'SEC-09': '의존성·CI/CD 권한·배포 자격증명·릴리스 산출물의 출처와 무결성을 확인해 공급망 위험을 통제한다.'
19
+ } as const;
20
+ export const SECURITY_AREA_IDS = Object.keys(SEC_AREAS) as Array<keyof typeof SEC_AREAS>;
21
+ export const STATUS_KO: Record<SecurityStatus, string> = {
22
+ passed: '점검 완료 - 이상 없음', needs_improvement: '점검 완료 - 보완 필요', failed: '점검 실패',
23
+ unverified: '미검증', not_applicable: '해당 없음'
24
+ };
25
+
26
+ export interface SecurityPolicy { version?: string; required_at?: Partial<Record<keyof typeof SEC_AREAS, SecurityStage[]>>; required?: string[]; digest?: string; }
27
+ export interface SecurityBinding { stage: SecurityStage; [key: string]: unknown }
28
+ export interface SecurityEvidence { reference: string; sha256: string; checker: string; checked_at: string; result?: string; }
29
+ export interface SecurityAreaRecord { id: keyof typeof SEC_AREAS; explanation: string; status: SecurityStatus; required_at: SecurityStage[]; evidence?: SecurityEvidence[]; reason?: string; finding?: string; severity?: string; owner?: string; follow_up?: string; mitigation?: string; }
30
+ export interface SecurityCheckInput { run_id?: string; time?: string; stage: SecurityStage; policy?: SecurityPolicy; policy_digest?: string; binding: SecurityBinding; environment: string; scope: string[]; checker: string; areas?: SecurityAreaRecord[]; evidence?: SecurityEvidence[]; retry_of?: string; }
31
+ export interface SecurityCheckResult { schema: 'yam.security-check-result.v1'; run_id: string; time: string; stage: SecurityStage; policy: SecurityPolicy; policy_digest: string; binding: SecurityBinding; environment: string; scope: string[]; checker: string; areas: SecurityAreaRecord[]; result: 'PASS' | 'BLOCKED'; blocking: string[]; deferred: string[]; errors: string[]; digest: string; retry_of?: string; }
32
+
33
+ const secretPatterns = [/https?:\/\/[^\s/]+:[^\s/]+@/i, /[?&](?:token|key|secret|password)=[^\s&]+/i, /\bgh[pousr]_[A-Za-z0-9]{20,}\b/i, /\bnpm_[A-Za-z0-9]{20,}\b/i, /\bsk-[A-Za-z0-9_-]{20,}\b/i, /\bAKIA[A-Z0-9]{16}\b/, /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, /(?:password|secret|token|api[_-]?key)\s*[:=]\s*[^\s,}]{8,}/i];
34
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
35
+ const unsafeText = (x: unknown): boolean => typeof x === 'string' && (/[\r\n\x00-\x1f\x7f<>]/.test(x) || x.length > 2048);
36
+ const iso = (x: unknown) => typeof x === 'string' && !Number.isNaN(Date.parse(x));
37
+ const obj = (x: unknown): x is Record<string, any> => !!x && typeof x === 'object' && !Array.isArray(x);
38
+ const stable = (x: unknown): string => {
39
+ if (Array.isArray(x)) return `[${x.map(stable).join(',')}]`;
40
+ if (x && typeof x === 'object') return `{${Object.keys(x as Record<string, unknown>).filter(k => (x as Record<string, unknown>)[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${stable((x as Record<string, unknown>)[k])}`).join(',')}}`;
41
+ return JSON.stringify(x);
42
+ };
43
+ const digest = (x: unknown) => `sha256:${createHash('sha256').update(stable(x)).digest('hex')}`;
44
+ const clone = <T>(x: T): T => JSON.parse(JSON.stringify(x));
45
+ const hasSecret = (x: unknown): boolean => secretPatterns.some((p) => p.test(typeof x === 'string' ? x : JSON.stringify(x)));
46
+
47
+ export function createSecurityStarter(input: { stage: SecurityStage; policy?: SecurityPolicy; binding: SecurityBinding; environment: string; scope?: string[]; checker?: string; now?: Date; run_id?: string }): SecurityCheckInput {
48
+ const policy = clone(input.policy || { version: 'security-check-v1', required_at: {} });
49
+ const requiredAt = policy.required_at || {};
50
+ return { run_id: input.run_id || `security-${randomUUID()}`, time: (input.now || new Date()).toISOString(), stage: input.stage, policy, policy_digest: policy.digest || digest(policy), binding: clone(input.binding), environment: input.environment, scope: input.scope || [], checker: input.checker || 'yam-security-check', areas: SECURITY_AREA_IDS.map((id) => ({ id, explanation: SEC_AREAS[id], status: 'unverified', required_at: requiredAt[id] || [input.stage] })) };
51
+ }
52
+
53
+ function evidenceErrors(e: SecurityEvidence, index: number): string[] {
54
+ const out: string[] = [];
55
+ if (!obj(e)) return [`evidence_invalid:${index}`];
56
+ if (typeof e.reference !== 'string' || !e.reference.trim() || e.reference.includes('\0')) out.push(`evidence_reference_invalid:${index}`);
57
+ if (!SHA256.test(String(e.sha256 || ''))) out.push(`evidence_sha256_invalid:${index}`);
58
+ if (typeof e.checker !== 'string' || !e.checker.trim() || hasSecret(e.checker)) out.push(`evidence_checker_invalid:${index}`);
59
+ if (!iso(e.checked_at)) out.push(`evidence_time_invalid:${index}`);
60
+ if (Object.values(e).some(unsafeText)) out.push(`evidence_text_invalid:${index}`);
61
+ if (hasSecret(e)) out.push(`evidence_secret:${index}`);
62
+ return out;
63
+ }
64
+
65
+ export function validateSecurityCheck(input: unknown, expectedBinding?: SecurityBinding, expectedPolicy?: SecurityPolicy): SecurityCheckResult {
66
+ const errors: string[] = [];
67
+ const x = obj(input) ? input as SecurityCheckInput : {} as SecurityCheckInput;
68
+ const validRun = typeof x.run_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(x.run_id);
69
+ const runId = validRun ? x.run_id : `invalid-${randomUUID()}`;
70
+ if (!validRun) errors.push('run_id_invalid');
71
+ if (!['commit', 'push', 'deploy'].includes(x.stage)) errors.push('stage_invalid');
72
+ if (!iso(x.time)) errors.push('time_invalid');
73
+ if (!obj(x.binding)) errors.push('binding_missing');
74
+ if (x.binding?.stage !== x.stage) errors.push('binding_stage_mismatch');
75
+ if (x.stage === 'deploy' && x.environment !== x.binding?.environment) errors.push('environment_binding_mismatch');
76
+ if (Object.values(x).filter(v => typeof v === 'string').some(unsafeText)) errors.push('record_text_invalid');
77
+ if (expectedBinding && stable(x.binding) !== stable(expectedBinding)) errors.push('binding_mismatch');
78
+ if (typeof x.environment !== 'string' || !x.environment.trim()) errors.push('environment_invalid');
79
+ if (!Array.isArray(x.scope) || !x.scope.length || x.scope.some(v => typeof v !== 'string' || !v.trim() || unsafeText(v))) errors.push('scope_invalid');
80
+ if (typeof x.checker !== 'string' || !x.checker.trim()) errors.push('checker_invalid');
81
+ const policy = obj(x.policy) ? clone(x.policy) : { version: 'security-check-v1', required_at: {} };
82
+ const policyDigest = x.policy_digest || policy.digest || digest(policy);
83
+ if (expectedPolicy && stable(policy) !== stable(expectedPolicy)) errors.push('policy_mismatch');
84
+ if (typeof x.policy_digest === 'string' && !SHA256.test(x.policy_digest)) errors.push('policy_digest_invalid');
85
+ if (!Array.isArray(x.areas)) errors.push('areas_missing');
86
+ const supplied = Array.isArray(x.areas) ? x.areas : [];
87
+ const byId = new Map<string, SecurityAreaRecord>();
88
+ supplied.forEach((a, i) => { if (!obj(a) || !Object.prototype.hasOwnProperty.call(SEC_AREAS, a.id)) errors.push(`area_invalid:${i}`); else if (byId.has(a.id)) errors.push(`area_duplicate:${a.id}`); else byId.set(a.id, clone(a)); });
89
+ const requiredAt = (expectedPolicy || policy).required_at || {};
90
+ if (!policy.version || unsafeText(policy.version)) errors.push('policy_version_invalid');
91
+ for (const id of SECURITY_AREA_IDS) {
92
+ const stages = requiredAt[id];
93
+ if (!Array.isArray(stages) || !stages.length || stages.some(v => !['commit','push','deploy'].includes(v))) errors.push(`policy_requirement_invalid:${id}`);
94
+ }
95
+ for (const id of SECURITY_AREA_IDS) if (!byId.has(id)) errors.push(`area_missing:${id}`);
96
+ const areas = SECURITY_AREA_IDS.map((id) => {
97
+ const a = byId.get(id) || { id, explanation: SEC_AREAS[id], status: 'unverified' as const, required_at: requiredAt[id] || [x.stage] };
98
+ if (a.explanation !== SEC_AREAS[id]) errors.push(`explanation_mismatch:${id}`);
99
+ if (!SECURITY_STATUSES.includes(a.status)) errors.push(`status_invalid:${id}`);
100
+ const trustedRequired = Array.isArray(requiredAt[id]) && requiredAt[id].length ? requiredAt[id] : ['commit','push','deploy'] as SecurityStage[];
101
+ if (Object.values(a).filter(v => typeof v === 'string').some(unsafeText)) errors.push(`area_text_invalid:${id}`);
102
+ if (a.status === 'passed' && Array.isArray(a.evidence) && a.evidence.some(e => e?.result !== 'passed')) errors.push(`evidence_result_mismatch:${id}`);
103
+ if (a.status === 'passed' && (!Array.isArray(a.evidence) || !a.evidence.length)) errors.push(`evidence_missing:${id}`);
104
+ if (['SEC-01','SEC-08'].includes(id) && (a.status === 'not_applicable' || !trustedRequired.includes(x.stage))) errors.push(`mandatory_area_invalid:${id}`);
105
+ if (JSON.stringify(a.required_at) !== JSON.stringify(trustedRequired)) errors.push(`required_at_tampered:${id}`);
106
+ if (a.status === 'not_applicable' && (!a.reason || !a.reason.trim())) errors.push(`not_applicable_reason_missing:${id}`);
107
+ if (a.status === 'needs_improvement' && (!a.finding || !a.severity || !a.owner || !a.follow_up || !a.mitigation)) errors.push(`improvement_details_missing:${id}`);
108
+ if (a.status === 'failed' && (!a.finding || !a.severity || !a.owner || !a.follow_up || !a.mitigation || !a.evidence?.length)) errors.push(`failure_details_missing:${id}`);
109
+ if (a.evidence && !Array.isArray(a.evidence)) errors.push(`evidence_array_invalid:${id}`);
110
+ (Array.isArray(a.evidence) ? a.evidence : []).forEach((e, i) => errors.push(...evidenceErrors(e, i).map((z) => `${id}:${z}`)));
111
+ return { ...a, explanation: SEC_AREAS[id], required_at: trustedRequired };
112
+ });
113
+ if (areas.filter((a) => a.id === 'SEC-01').length !== 1 || areas.filter((a) => a.id === 'SEC-08').length !== 1) errors.push('mandatory_areas_missing');
114
+ if (hasSecret(x)) errors.push('record_contains_secret');
115
+ const blocking = areas.filter((a) => (a.required_at || []).includes(x.stage) && !['passed', 'not_applicable'].includes(a.status)).map((a) => `${a.id}:${a.status}`);
116
+ const allErrors = [...new Set(errors)];
117
+ const finalBlocking = [...new Set([...blocking, ...allErrors.map((e) => `contract:${e}`)])];
118
+ const result = finalBlocking.length ? 'BLOCKED' as const : 'PASS' as const;
119
+ const canonical = { schema: 'yam.security-check-result.v1' as const, retry_of: x.retry_of || 'none', run_id: runId, time: x.time, stage: x.stage, policy, policy_digest: policyDigest, binding: x.binding, environment: x.environment, scope: x.scope || [], checker: x.checker, areas, result, blocking: finalBlocking, deferred: areas.filter((a) => !(a.required_at || []).includes(x.stage) && a.status === 'unverified').map((a) => a.id), errors: allErrors };
120
+ return { ...canonical, digest: digest(canonical) };
121
+ }
122
+
123
+ function ensureSafePath(root: string, target: string): string {
124
+ const r = path.resolve(root); const t = path.resolve(target); if (t !== r && !t.startsWith(`${r}${path.sep}`)) throw new Error('path_outside_root'); return t;
125
+ }
126
+ async function noSymlinkComponents(root: string, target: string) {
127
+ const rel = path.relative(root, target); let cur = root;
128
+ for (const part of rel.split(path.sep).filter(Boolean)) { cur = path.join(cur, part); try { if ((await fsp.lstat(cur)).isSymbolicLink()) throw new Error('symlink_path_rejected'); } catch (e: any) { if (e.code !== 'ENOENT') throw e; } }
129
+ }
130
+ async function withLock(lock: string, fn: () => Promise<void>) { for (let i = 0; i < 50; i++) { try { await fsp.mkdir(lock); break; } catch (e: any) { if (e.code !== 'EEXIST') throw e; await new Promise((r) => setTimeout(r, 10)); } if (i === 49) throw new Error('security_note_lock_timeout'); } try { await fn(); } finally { await fsp.rm(lock, { recursive: true, force: true }); } }
131
+
132
+ export async function persistSecurityCheck(dir: string, input: SecurityCheckInput, result?: SecurityCheckResult, options: { notePath?: string } = {}) {
133
+ const root = await fsp.realpath(dir);
134
+ let checked = validateSecurityCheck(input);
135
+ // Integration may only add blocking findings; it cannot promote a failed contract.
136
+ if (result?.result === 'BLOCKED') {
137
+ checked = { ...checked, result: 'BLOCKED', errors: [...new Set([...checked.errors, ...result.errors])], blocking: [...new Set([...checked.blocking, ...result.blocking])] };
138
+ }
139
+ if (hasSecret(checked) || checked.errors.some(e => /text_invalid|secret/.test(e))) {
140
+ const sanitized = createSecurityStarter({ stage: ['commit','push','deploy'].includes(input?.stage) ? input.stage : 'commit', binding: { stage: input?.stage || 'commit', withheld: true }, environment: 'withheld', scope: ['unsafe input omitted'], checker: 'yam-security-check' });
141
+ checked = validateSecurityCheck(sanitized);
142
+ checked.errors.push('unsafe_input_redacted'); checked.blocking.push('contract:unsafe_input_redacted');
143
+ }
144
+ checked.digest = digest({ ...checked, digest: undefined });
145
+ const base = ensureSafePath(root, path.join(root, '.yam', 'security'));
146
+ await noSymlinkComponents(root, base);
147
+ await fsp.mkdir(base, { recursive: true, mode: 0o700 });
148
+ const runs = ensureSafePath(root, path.join(base, 'runs'));
149
+ const originalRoot = path.resolve(dir);
150
+ const requestedNote = options.notePath ? path.resolve(originalRoot, options.notePath) : path.join(originalRoot, '.yam', 'security', 'notes.md');
151
+ ensureSafePath(originalRoot, requestedNote);
152
+ const note = ensureSafePath(root, path.resolve(root, path.relative(originalRoot, requestedNote)));
153
+ const lock = path.join(base, '.lock');
154
+ await noSymlinkComponents(root, note);
155
+ await noSymlinkComponents(root, runs);
156
+ let runPath = '';
157
+ await withLock(lock, async () => {
158
+ await noSymlinkComponents(root, note);
159
+ await fsp.mkdir(path.dirname(note), { recursive: true, mode: 0o700 });
160
+ await fsp.mkdir(runs, { recursive: true, mode: 0o700 });
161
+ runPath = ensureSafePath(runs, path.join(runs, `${checked.run_id}.json`));
162
+ await noSymlinkComponents(root, runPath);
163
+ try { await fsp.lstat(runPath); throw new Error('security_run_exists'); } catch (e: any) { if (e.code !== 'ENOENT') throw e; }
164
+ const handle = await fsp.open(note, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW, 0o600);
165
+ try {
166
+ const info = await handle.stat(); if (!info.isFile() || info.nlink !== 1) throw new Error('unsafe_note_file');
167
+ await fsp.writeFile(runPath, JSON.stringify(checked, null, 2) + '\n', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
168
+ await handle.writeFile(renderSecurityNote(checked), 'utf8'); await handle.sync();
169
+ } finally { await handle.close(); }
170
+ });
171
+ return { run_path: path.relative(root, runPath), note_path: path.relative(root, note), result: checked };
172
+ }
173
+
174
+ export function renderSecurityNote(result: SecurityCheckResult): string {
175
+ const clean = (x: unknown) => String(x ?? '').replace(/[\r\n\x00-\x1f\x7f]/g, ' ');
176
+ const lines = ['-- Security check --', '', 'Contract: security-check-v1', `Run: ${clean(result.run_id)}; Time: ${clean(result.time)}`, `Stage: ${result.stage}; Policy: ${clean(result.policy_digest)}`, `Environment: ${clean(result.environment)}`, `Binding: ${clean(JSON.stringify(result.binding))}`, `Scope: ${result.scope.map(clean).join(', ')}`, `Checker: ${clean(result.checker)}`, ''];
177
+ result.areas.forEach((a, i) => {
178
+ lines.push(`${i + 1}. ${a.id} : "${SEC_AREAS[a.id]}" : ${STATUS_KO[a.status] || '미검증'}`);
179
+ lines.push(` - Required at: ${a.required_at.join(', ')}`);
180
+ if (Array.isArray(a.evidence) && a.evidence.length) lines.push(` - Evidence: ${a.evidence.map(e => `${clean(e.reference)} (${clean(e.sha256)}) by ${clean(e.checker)} at ${clean(e.checked_at)}`).join('; ')}`);
181
+ for (const field of ['reason','finding','severity','owner','follow_up','mitigation'] as const) if (a[field]) lines.push(` - ${field}: ${clean(a[field])}`);
182
+ });
183
+ lines.push('', `Retry of: ${clean(result.retry_of || 'none')}`, `Result: ${result.result}`, `Blocking: ${result.blocking.map(clean).join(', ') || 'none'}`, `Deferred: ${result.deferred.join(', ') || 'none'}`, '-- End Security check --', ''); return lines.join('\n');
184
+ }