@flowcodex/core 0.3.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/index-LbxYtxxS.d.ts +560 -0
  4. package/dist/index.d.ts +995 -0
  5. package/dist/index.js +3840 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/kernel/index.d.ts +1 -0
  8. package/dist/kernel/index.js +551 -0
  9. package/dist/kernel/index.js.map +1 -0
  10. package/package.json +39 -0
  11. package/src/agent/agent-loop.ts +254 -0
  12. package/src/agent/context.ts +99 -0
  13. package/src/agent/conversation-state.ts +44 -0
  14. package/src/agent/provider-runner.ts +241 -0
  15. package/src/agent/system-prompt-builder.ts +193 -0
  16. package/src/execution/compactor.ts +256 -0
  17. package/src/execution/index.ts +7 -0
  18. package/src/execution/output-serializer.ts +90 -0
  19. package/src/execution/schema-validator.ts +124 -0
  20. package/src/execution/tool-executor.ts +276 -0
  21. package/src/execution/tool-registry.ts +104 -0
  22. package/src/index.ts +215 -0
  23. package/src/infrastructure/catalog-parser.ts +218 -0
  24. package/src/infrastructure/index.ts +16 -0
  25. package/src/infrastructure/path-resolver.ts +123 -0
  26. package/src/infrastructure/provider-factory.ts +116 -0
  27. package/src/infrastructure/provider-presets.ts +19 -0
  28. package/src/infrastructure/retry-policy.ts +50 -0
  29. package/src/infrastructure/secret-scrubber.ts +67 -0
  30. package/src/infrastructure/token-counter.ts +156 -0
  31. package/src/infrastructure/tracer.ts +23 -0
  32. package/src/kernel/container.ts +166 -0
  33. package/src/kernel/events.ts +323 -0
  34. package/src/kernel/index.ts +18 -0
  35. package/src/kernel/pipeline.ts +152 -0
  36. package/src/kernel/run-controller.ts +85 -0
  37. package/src/kernel/tokens.ts +21 -0
  38. package/src/security/index.ts +13 -0
  39. package/src/security/permission-policy.ts +273 -0
  40. package/src/session/audit-log.ts +201 -0
  41. package/src/session/auth-service.ts +178 -0
  42. package/src/session/index.ts +26 -0
  43. package/src/session/secret-vault.ts +183 -0
  44. package/src/session/session-store.ts +339 -0
  45. package/src/session/types.ts +100 -0
  46. package/src/types/blocks.ts +56 -0
  47. package/src/types/context.ts +54 -0
  48. package/src/types/errors.ts +359 -0
  49. package/src/types/index.ts +34 -0
  50. package/src/types/provider.ts +58 -0
  51. package/src/types/tool.ts +39 -0
  52. package/src/utils/error.ts +3 -0
  53. package/src/utils/fs.ts +185 -0
  54. package/src/utils/image-resize.ts +76 -0
  55. package/src/utils/ssrf-guard.ts +133 -0
  56. package/src/utils/ulid.ts +72 -0
  57. package/src/utils/version-check.ts +59 -0
  58. package/tests/agent-loop.test.ts +490 -0
  59. package/tests/audit-log.test.ts +199 -0
  60. package/tests/auth-service.test.ts +170 -0
  61. package/tests/blocks.test.ts +79 -0
  62. package/tests/catalog-parser.test.ts +174 -0
  63. package/tests/compactor.test.ts +180 -0
  64. package/tests/container.test.ts +224 -0
  65. package/tests/conversation-state.test.ts +75 -0
  66. package/tests/errors.test.ts +429 -0
  67. package/tests/events-v021.test.ts +60 -0
  68. package/tests/events-v022.test.ts +75 -0
  69. package/tests/events.test.ts +340 -0
  70. package/tests/fixtures/large-image.png +0 -0
  71. package/tests/fixtures/small-image.png +0 -0
  72. package/tests/fs-utils.test.ts +164 -0
  73. package/tests/image-resize.test.ts +51 -0
  74. package/tests/output-serializer.test.ts +79 -0
  75. package/tests/path-resolver.test.ts +91 -0
  76. package/tests/permission-policy.test.ts +174 -0
  77. package/tests/pipeline.test.ts +193 -0
  78. package/tests/provider-factory.test.ts +245 -0
  79. package/tests/provider-runner.test.ts +535 -0
  80. package/tests/retry-policy.test.ts +104 -0
  81. package/tests/run-controller.test.ts +115 -0
  82. package/tests/sanity.test.ts +26 -0
  83. package/tests/schema-validator.test.ts +109 -0
  84. package/tests/secret-scrubber.test.ts +133 -0
  85. package/tests/secret-vault.test.ts +130 -0
  86. package/tests/session-store.test.ts +429 -0
  87. package/tests/ssrf-guard.test.ts +112 -0
  88. package/tests/system-prompt-builder.test.ts +116 -0
  89. package/tests/token-counter.test.ts +163 -0
  90. package/tests/tokens.test.ts +42 -0
  91. package/tests/tool-executor.test.ts +452 -0
  92. package/tests/tool-registry.test.ts +143 -0
  93. package/tests/tracer.test.ts +32 -0
  94. package/tests/ulid.test.ts +53 -0
  95. package/tests/version-check.test.ts +57 -0
  96. package/tsconfig.json +11 -0
  97. package/tsup.config.ts +16 -0
@@ -0,0 +1,201 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fsp } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import type { AuditEntry, AuditLog, VerifyResult } from './types.js';
5
+
6
+ const GENESIS_PREV = '0'.repeat(64);
7
+ const DEFAULT_FSYNC_EVERY = 100;
8
+
9
+ export interface AuditLogOptions {
10
+ dir: string;
11
+ fsyncEvery?: number | undefined;
12
+ }
13
+
14
+ export class DefaultAuditLog implements AuditLog {
15
+ private readonly dir: string;
16
+ private readonly fsyncEvery: number;
17
+ private readonly tailHash = new Map<string, string>();
18
+ private readonly tailIndex = new Map<string, number>();
19
+ private readonly unSyncedWrites = new Map<string, number>();
20
+
21
+ constructor(opts: AuditLogOptions) {
22
+ this.dir = opts.dir;
23
+ this.fsyncEvery = opts.fsyncEvery ?? DEFAULT_FSYNC_EVERY;
24
+ }
25
+
26
+ private filePath(sessionId: string): string {
27
+ return path.join(this.dir, `${sessionId}.audit.jsonl`);
28
+ }
29
+
30
+ async record(input: {
31
+ sessionId: string;
32
+ toolName: string;
33
+ toolUseId: string;
34
+ input: unknown;
35
+ output: unknown;
36
+ isError: boolean;
37
+ }): Promise<AuditEntry> {
38
+ const fp = this.filePath(input.sessionId);
39
+ await fsp.mkdir(path.dirname(fp), { recursive: true }).catch(() => {});
40
+
41
+ let prevHash = this.tailHash.get(input.sessionId);
42
+ let index = this.tailIndex.get(input.sessionId) ?? 0;
43
+
44
+ if (prevHash === undefined || index === 0) {
45
+ const entries = await this.readAll(input.sessionId);
46
+ const last = entries.at(-1);
47
+ prevHash = last?.hash ?? GENESIS_PREV;
48
+ index = last ? last.index + 1 : 0;
49
+ }
50
+
51
+ const id = randomUUID();
52
+ const ts = new Date().toISOString();
53
+
54
+ const content = {
55
+ id,
56
+ ts,
57
+ prevHash,
58
+ toolName: input.toolName,
59
+ toolUseId: input.toolUseId,
60
+ input: input.input,
61
+ output: input.output,
62
+ isError: input.isError,
63
+ index,
64
+ };
65
+ const hash = createHash('sha256').update(stableStringify(content), 'utf8').digest('hex');
66
+
67
+ const entry: AuditEntry = {
68
+ ...content,
69
+ hash,
70
+ };
71
+
72
+ const line = JSON.stringify(entry) + '\n';
73
+ await fsp.appendFile(fp, line, 'utf8');
74
+
75
+ this.tailHash.set(input.sessionId, hash);
76
+ this.tailIndex.set(input.sessionId, index + 1);
77
+
78
+ const count = (this.unSyncedWrites.get(input.sessionId) ?? 0) + 1;
79
+ this.unSyncedWrites.set(input.sessionId, count);
80
+ if (this.fsyncEvery !== Number.POSITIVE_INFINITY && count % this.fsyncEvery === 0) {
81
+ await this.sync(fp);
82
+ }
83
+
84
+ return entry;
85
+ }
86
+
87
+ async verify(sessionId: string): Promise<VerifyResult> {
88
+ let entries: AuditEntry[];
89
+ try {
90
+ entries = await this.readAll(sessionId);
91
+ } catch {
92
+ return { ok: true, entries: 0 };
93
+ }
94
+
95
+ if (entries.length === 0) return { ok: true, entries: 0 };
96
+
97
+ if (entries[0]?.prevHash !== GENESIS_PREV) {
98
+ return {
99
+ ok: false,
100
+ brokenAt: 0,
101
+ reason: 'first entry prevHash is not genesis (all-zeros)',
102
+ };
103
+ }
104
+
105
+ let prevHash = GENESIS_PREV;
106
+ for (let i = 0; i < entries.length; i++) {
107
+ const e = entries[i];
108
+ if (!e) continue;
109
+ if (e.prevHash !== prevHash) {
110
+ return {
111
+ ok: false,
112
+ brokenAt: i,
113
+ reason: `prevHash mismatch at entry ${i}`,
114
+ };
115
+ }
116
+ const content = {
117
+ id: e.id,
118
+ ts: e.ts,
119
+ prevHash: e.prevHash,
120
+ toolName: e.toolName,
121
+ toolUseId: e.toolUseId,
122
+ input: e.input,
123
+ output: e.output,
124
+ isError: e.isError,
125
+ index: e.index,
126
+ };
127
+ const expectedHash = createHash('sha256')
128
+ .update(stableStringify(content), 'utf8')
129
+ .digest('hex');
130
+ if (expectedHash !== e.hash) {
131
+ return {
132
+ ok: false,
133
+ brokenAt: i,
134
+ reason: `hash mismatch at entry ${i} (content was modified)`,
135
+ };
136
+ }
137
+ prevHash = e.hash;
138
+ }
139
+
140
+ return { ok: true, entries: entries.length };
141
+ }
142
+
143
+ async load(sessionId: string): Promise<AuditEntry[]> {
144
+ return this.readAll(sessionId);
145
+ }
146
+
147
+ async flush(sessionId: string): Promise<void> {
148
+ await this.sync(this.filePath(sessionId));
149
+ }
150
+
151
+ private async readAll(sessionId: string): Promise<AuditEntry[]> {
152
+ const fp = this.filePath(sessionId);
153
+ let raw: string;
154
+ try {
155
+ raw = await fsp.readFile(fp, 'utf8');
156
+ } catch (err) {
157
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
158
+ throw err;
159
+ }
160
+ const out: AuditEntry[] = [];
161
+ for (const line of raw.split('\n')) {
162
+ if (!line.trim()) continue;
163
+ try {
164
+ out.push(JSON.parse(line) as AuditEntry);
165
+ } catch {
166
+ // skip corrupt lines
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ private async sync(fp: string): Promise<void> {
173
+ try {
174
+ const fh = await fsp.open(fp, 'r+');
175
+ try {
176
+ await fh.sync();
177
+ } finally {
178
+ await fh.close();
179
+ }
180
+ } catch {
181
+ // best-effort
182
+ }
183
+ }
184
+ }
185
+
186
+ export function stableStringify(value: unknown): string {
187
+ return JSON.stringify(sortKeys(value));
188
+ }
189
+
190
+ function sortKeys(value: unknown): unknown {
191
+ if (Array.isArray(value)) return value.map(sortKeys);
192
+ if (value && typeof value === 'object') {
193
+ const obj = value as Record<string, unknown>;
194
+ const sorted: Record<string, unknown> = {};
195
+ for (const key of Object.keys(obj).sort()) {
196
+ sorted[key] = sortKeys(obj[key]);
197
+ }
198
+ return sorted;
199
+ }
200
+ return value;
201
+ }
@@ -0,0 +1,178 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import { DefaultSecretVault, restrictFilePermissions } from './secret-vault.js';
3
+ import type { SecretVault } from './types.js';
4
+ import { atomicWrite } from '../utils/fs.js';
5
+
6
+ export interface ApiKeyEntry {
7
+ label: string;
8
+ key: string;
9
+ createdAt: string;
10
+ }
11
+
12
+ export interface AuthEntry {
13
+ providerId: string;
14
+ keys: ApiKeyEntry[];
15
+ activeLabel: string;
16
+ }
17
+
18
+ export interface AuthProviderStatus {
19
+ providerId: string;
20
+ keyCount: number;
21
+ activeLabel: string | undefined;
22
+ lastUsed: string | undefined;
23
+ envVarDetected: boolean;
24
+ }
25
+
26
+ export interface AuthService {
27
+ load(): Promise<void>;
28
+ get(providerId: string): ApiKeyEntry | undefined;
29
+ all(): Record<string, ApiKeyEntry[]>;
30
+ set(providerId: string, key: string, label?: string): Promise<void>;
31
+ remove(providerId: string, label?: string): Promise<void>;
32
+ list(): Promise<AuthEntry[]>;
33
+ status(providerId: string): Promise<AuthProviderStatus>;
34
+ setActive(providerId: string, label: string): Promise<void>;
35
+ }
36
+
37
+ interface AuthFile {
38
+ version: number;
39
+ providers: Record<
40
+ string,
41
+ {
42
+ activeLabel: string;
43
+ keys: ApiKeyEntry[];
44
+ }
45
+ >;
46
+ }
47
+
48
+ export interface AuthServiceOptions {
49
+ authFile: string;
50
+ keyFile: string;
51
+ }
52
+
53
+ const AUTH_FILE_VERSION = 1;
54
+
55
+ export class DefaultAuthService implements AuthService {
56
+ private vault: SecretVault;
57
+ private authFile: string;
58
+ private data: AuthFile = { version: AUTH_FILE_VERSION, providers: {} };
59
+
60
+ constructor(opts: AuthServiceOptions) {
61
+ this.authFile = opts.authFile;
62
+ this.vault = new DefaultSecretVault({ keyFile: opts.keyFile });
63
+ }
64
+
65
+ async load(): Promise<void> {
66
+ try {
67
+ const raw = await fsp.readFile(this.authFile, 'utf-8');
68
+ const decrypted = this.vault.decrypt(raw);
69
+ const parsed = JSON.parse(decrypted) as AuthFile;
70
+ if (parsed && typeof parsed === 'object' && parsed.providers) {
71
+ this.data = parsed;
72
+ }
73
+ } catch (err) {
74
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
75
+ this.data = { version: AUTH_FILE_VERSION, providers: {} };
76
+ return;
77
+ }
78
+ this.data = { version: AUTH_FILE_VERSION, providers: {} };
79
+ }
80
+ }
81
+
82
+ get(providerId: string): ApiKeyEntry | undefined {
83
+ const provider = this.data.providers[providerId];
84
+ if (!provider) return undefined;
85
+ return provider.keys.find((k) => k.label === provider.activeLabel) ?? provider.keys[0];
86
+ }
87
+
88
+ all(): Record<string, ApiKeyEntry[]> {
89
+ const result: Record<string, ApiKeyEntry[]> = {};
90
+ for (const [id, provider] of Object.entries(this.data.providers)) {
91
+ result[id] = provider.keys;
92
+ }
93
+ return result;
94
+ }
95
+
96
+ async set(providerId: string, key: string, label?: string): Promise<void> {
97
+ const existing = this.data.providers[providerId];
98
+ const provider = existing ?? { activeLabel: '', keys: [] as ApiKeyEntry[] };
99
+ if (!existing) {
100
+ this.data.providers[providerId] = provider;
101
+ }
102
+ const finalLabel =
103
+ label ?? (provider.keys.length === 0 ? 'default' : `key-${provider.keys.length + 1}`);
104
+ provider.keys = provider.keys.filter((k) => k.label !== finalLabel);
105
+ provider.keys.push({
106
+ label: finalLabel,
107
+ key,
108
+ createdAt: new Date().toISOString(),
109
+ });
110
+ if (!provider.activeLabel || provider.keys.length === 1) {
111
+ provider.activeLabel = finalLabel;
112
+ }
113
+ await this.save();
114
+ }
115
+
116
+ async remove(providerId: string, label?: string): Promise<void> {
117
+ const provider = this.data.providers[providerId];
118
+ if (!provider) return;
119
+ if (label === undefined) {
120
+ delete this.data.providers[providerId];
121
+ } else {
122
+ provider.keys = provider.keys.filter((k) => k.label !== label);
123
+ if (provider.activeLabel === label) {
124
+ provider.activeLabel = provider.keys[0]?.label ?? '';
125
+ }
126
+ if (provider.keys.length === 0) {
127
+ delete this.data.providers[providerId];
128
+ }
129
+ }
130
+ await this.save();
131
+ }
132
+
133
+ async list(): Promise<AuthEntry[]> {
134
+ const entries: AuthEntry[] = [];
135
+ for (const [id, provider] of Object.entries(this.data.providers)) {
136
+ entries.push({
137
+ providerId: id,
138
+ keys: provider.keys,
139
+ activeLabel: provider.activeLabel,
140
+ });
141
+ }
142
+ return entries;
143
+ }
144
+
145
+ async status(providerId: string): Promise<AuthProviderStatus> {
146
+ const provider = this.data.providers[providerId];
147
+ const envVar =
148
+ providerId === 'anthropic' ? 'ANTHROPIC_API_KEY' : `${providerId.toUpperCase()}_API_KEY`;
149
+ const lastUsed = provider?.keys.length ? provider.keys[provider.keys.length - 1]?.createdAt : undefined;
150
+ return {
151
+ providerId,
152
+ keyCount: provider?.keys.length ?? 0,
153
+ activeLabel: provider?.activeLabel || undefined,
154
+ lastUsed,
155
+ envVarDetected: process.env[envVar] !== undefined,
156
+ };
157
+ }
158
+
159
+ async setActive(providerId: string, label: string): Promise<void> {
160
+ const provider = this.data.providers[providerId];
161
+ if (!provider) {
162
+ throw new Error(`No keys for provider "${providerId}"`);
163
+ }
164
+ const exists = provider.keys.some((k) => k.label === label);
165
+ if (!exists) {
166
+ throw new Error(`No key with label "${label}" for provider "${providerId}"`);
167
+ }
168
+ provider.activeLabel = label;
169
+ await this.save();
170
+ }
171
+
172
+ private async save(): Promise<void> {
173
+ const json = JSON.stringify(this.data, null, 2);
174
+ const encrypted = this.vault.encrypt(json);
175
+ await atomicWrite(this.authFile, encrypted);
176
+ await restrictFilePermissions(this.authFile);
177
+ }
178
+ }
@@ -0,0 +1,26 @@
1
+ export type { SessionEvent, SessionSummary, AuditEntry, VerifyResult, SessionStore, SecretVault, AuditLog } from './types.js';
2
+ export { DefaultSecretVault, ENCRYPTED_PREFIX, restrictFilePermissions, isSecretField } from './secret-vault.js';
3
+ export type { SecretVaultOptions } from './secret-vault.js';
4
+ export {
5
+ DefaultSessionStore,
6
+ generateSessionId,
7
+ projectHash,
8
+ flowcodexHome,
9
+ sessionsDir,
10
+ recordsDir,
11
+ reconstructMessages,
12
+ keyFilePath,
13
+ configFilePath,
14
+ authFilePath,
15
+ } from './session-store.js';
16
+ export type { SessionStoreOptions } from './session-store.js';
17
+ export { DefaultAuditLog, stableStringify } from './audit-log.js';
18
+ export type { AuditLogOptions } from './audit-log.js';
19
+ export type {
20
+ ApiKeyEntry,
21
+ AuthEntry,
22
+ AuthProviderStatus,
23
+ AuthService,
24
+ AuthServiceOptions,
25
+ } from './auth-service.js';
26
+ export { DefaultAuthService } from './auth-service.js';
@@ -0,0 +1,183 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import * as fs from 'node:fs';
3
+ import * as fsp from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import type { SecretVault } from './types.js';
6
+ import { ConfigError, ERROR_CODES } from '../types/errors.js';
7
+
8
+ export const ENCRYPTED_PREFIX = 'enc:v1:';
9
+
10
+ const KEY_BYTES = 32;
11
+ const IV_BYTES = 12;
12
+ const TAG_BYTES = 16;
13
+ const ALGO = 'aes-256-gcm';
14
+ const KEY_FILE_MODE = 0o600;
15
+
16
+ export interface SecretVaultOptions {
17
+ keyFile: string;
18
+ }
19
+
20
+ export class DefaultSecretVault implements SecretVault {
21
+ private readonly keyFile: string;
22
+ private key?: Buffer | undefined;
23
+
24
+ constructor(opts: SecretVaultOptions) {
25
+ this.keyFile = opts.keyFile;
26
+ }
27
+
28
+ isEncrypted(value: string): boolean {
29
+ return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX);
30
+ }
31
+
32
+ encrypt(plaintext: string): string {
33
+ if (this.isEncrypted(plaintext)) return plaintext;
34
+ const key = this.loadOrCreateKey();
35
+ const iv = randomBytes(IV_BYTES);
36
+ const cipher = createCipheriv(ALGO, key, iv);
37
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
38
+ const tag = cipher.getAuthTag();
39
+ return `${ENCRYPTED_PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`;
40
+ }
41
+
42
+ decrypt(value: string): string {
43
+ if (!this.isEncrypted(value)) return value;
44
+ const rest = value.slice(ENCRYPTED_PREFIX.length);
45
+ const parts = rest.split(':');
46
+ if (parts.length !== 3) {
47
+ throw new ConfigError({
48
+ message: 'SecretVault: malformed encrypted value',
49
+ code: ERROR_CODES.CONFIG_PARSE_FAILED,
50
+ context: { field: 'encrypted_value' },
51
+ });
52
+ }
53
+ const [ivB64, tagB64, ctB64] = parts as [string, string, string];
54
+ const iv = Buffer.from(ivB64, 'base64');
55
+ const tag = Buffer.from(tagB64, 'base64');
56
+ const ct = Buffer.from(ctB64, 'base64');
57
+ if (iv.length !== IV_BYTES) {
58
+ throw new ConfigError({
59
+ message: `SecretVault: bad IV length (${iv.length}, expected ${IV_BYTES})`,
60
+ code: ERROR_CODES.CONFIG_PARSE_FAILED,
61
+ context: { expected: IV_BYTES, actual: iv.length },
62
+ });
63
+ }
64
+ if (tag.length !== TAG_BYTES) {
65
+ throw new ConfigError({
66
+ message: `SecretVault: bad tag length (${tag.length}, expected ${TAG_BYTES})`,
67
+ code: ERROR_CODES.CONFIG_PARSE_FAILED,
68
+ context: { expected: TAG_BYTES, actual: tag.length },
69
+ });
70
+ }
71
+ const key = this.loadOrCreateKey();
72
+ const decipher = createDecipheriv(ALGO, key, iv);
73
+ decipher.setAuthTag(tag);
74
+ const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
75
+ return pt.toString('utf8');
76
+ }
77
+
78
+ private loadOrCreateKey(): Buffer {
79
+ if (this.key) return this.key;
80
+ try {
81
+ const buf = fs.readFileSync(this.keyFile);
82
+ if (buf.length !== KEY_BYTES) {
83
+ throw new ConfigError({
84
+ message: `SecretVault: key file ${this.keyFile} is ${buf.length} bytes (expected ${KEY_BYTES}). Remove it to regenerate.`,
85
+ code: ERROR_CODES.CONFIG_INVALID,
86
+ context: { keyFile: this.keyFile, expectedBytes: KEY_BYTES, actualBytes: buf.length },
87
+ });
88
+ }
89
+ this.key = buf;
90
+ checkKeyFilePermissions(this.keyFile);
91
+ return this.key;
92
+ } catch (err) {
93
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
94
+ }
95
+ fs.mkdirSync(path.dirname(this.keyFile), { recursive: true });
96
+ const newKey = randomBytes(KEY_BYTES);
97
+ try {
98
+ fs.writeFileSync(this.keyFile, newKey, { mode: KEY_FILE_MODE, flag: 'wx' });
99
+ } catch (err) {
100
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
101
+ const buf = fs.readFileSync(this.keyFile);
102
+ if (buf.length !== KEY_BYTES) {
103
+ throw new ConfigError({
104
+ message: `SecretVault: key file ${this.keyFile} is ${buf.length} bytes (expected ${KEY_BYTES}). Remove it to regenerate.`,
105
+ code: ERROR_CODES.CONFIG_INVALID,
106
+ context: { keyFile: this.keyFile, expectedBytes: KEY_BYTES, actualBytes: buf.length },
107
+ });
108
+ }
109
+ this.key = buf;
110
+ checkKeyFilePermissions(this.keyFile);
111
+ return this.key;
112
+ }
113
+ this.key = newKey;
114
+ return newKey;
115
+ }
116
+ }
117
+
118
+ function checkKeyFilePermissions(keyFile: string): void {
119
+ if (process.platform === 'win32') return;
120
+ try {
121
+ const stat = fs.statSync(keyFile);
122
+ const actualMode = stat.mode & 0o777;
123
+ if (actualMode !== KEY_FILE_MODE) {
124
+ process.stderr.write(
125
+ JSON.stringify({
126
+ level: 'warn',
127
+ event: 'vault.key_file_wrong_permissions',
128
+ message: `Key file ${keyFile} has mode ${actualMode.toString(8)} — expected ${KEY_FILE_MODE.toString(8)}`,
129
+ timestamp: new Date().toISOString(),
130
+ }) + '\n',
131
+ );
132
+ }
133
+ } catch {
134
+ // stat failed, not critical
135
+ }
136
+ }
137
+
138
+ export async function restrictFilePermissions(
139
+ filePath: string,
140
+ opts?: { warn?: ((msg: string) => void) | undefined },
141
+ ): Promise<void> {
142
+ const warn = opts?.warn ?? ((msg: string) => process.stderr.write(msg + '\n'));
143
+ if (process.platform === 'win32') {
144
+ try {
145
+ const { execFile } = await import('node:child_process');
146
+ const { promisify } = await import('node:util');
147
+ const execFileAsync = promisify(execFile);
148
+ const user = windowsAccountName();
149
+ if (!user) {
150
+ warn(`[secret-vault] Could not determine Windows user for ${filePath}; skipping icacls.`);
151
+ return;
152
+ }
153
+ await execFileAsync('icacls', [filePath, '/inheritance:r', '/grant:r', `${user}:(F)`]);
154
+ } catch {
155
+ warn(`[secret-vault] Could not restrict permissions on ${filePath}.`);
156
+ }
157
+ } else {
158
+ try {
159
+ await fsp.chmod(filePath, 0o600);
160
+ } catch {
161
+ // best-effort
162
+ }
163
+ }
164
+ }
165
+
166
+ function windowsAccountName(): string | undefined {
167
+ const username = process.env.USERNAME || process.env.USER;
168
+ if (!username || username.includes('\0')) return undefined;
169
+ const domain = process.env.USERDOMAIN;
170
+ if (domain && !domain.includes('\0')) return `${domain}\\${username}`;
171
+ return username;
172
+ }
173
+
174
+ const SECRET_KEY_PATTERN =
175
+ /(?:apikey|api_key|authtoken|auth_token|bearer|secret|password|passwd|pwd|refreshtoken|refresh_token|sessionkey|session_key|access[_-]?token|private[_-]?key)/i;
176
+
177
+ const NON_SECRET_OVERRIDES = new Set(['publickey', 'public_key']);
178
+
179
+ export function isSecretField(name: string): boolean {
180
+ const lc = name.toLowerCase();
181
+ if (NON_SECRET_OVERRIDES.has(lc)) return false;
182
+ return SECRET_KEY_PATTERN.test(lc);
183
+ }