@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index-LbxYtxxS.d.ts +560 -0
- package/dist/index.d.ts +995 -0
- package/dist/index.js +3840 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel/index.d.ts +1 -0
- package/dist/kernel/index.js +551 -0
- package/dist/kernel/index.js.map +1 -0
- package/package.json +39 -0
- package/src/agent/agent-loop.ts +254 -0
- package/src/agent/context.ts +99 -0
- package/src/agent/conversation-state.ts +44 -0
- package/src/agent/provider-runner.ts +241 -0
- package/src/agent/system-prompt-builder.ts +193 -0
- package/src/execution/compactor.ts +256 -0
- package/src/execution/index.ts +7 -0
- package/src/execution/output-serializer.ts +90 -0
- package/src/execution/schema-validator.ts +124 -0
- package/src/execution/tool-executor.ts +276 -0
- package/src/execution/tool-registry.ts +104 -0
- package/src/index.ts +215 -0
- package/src/infrastructure/catalog-parser.ts +218 -0
- package/src/infrastructure/index.ts +16 -0
- package/src/infrastructure/path-resolver.ts +123 -0
- package/src/infrastructure/provider-factory.ts +116 -0
- package/src/infrastructure/provider-presets.ts +19 -0
- package/src/infrastructure/retry-policy.ts +50 -0
- package/src/infrastructure/secret-scrubber.ts +67 -0
- package/src/infrastructure/token-counter.ts +156 -0
- package/src/infrastructure/tracer.ts +23 -0
- package/src/kernel/container.ts +166 -0
- package/src/kernel/events.ts +323 -0
- package/src/kernel/index.ts +18 -0
- package/src/kernel/pipeline.ts +152 -0
- package/src/kernel/run-controller.ts +85 -0
- package/src/kernel/tokens.ts +21 -0
- package/src/security/index.ts +13 -0
- package/src/security/permission-policy.ts +273 -0
- package/src/session/audit-log.ts +201 -0
- package/src/session/auth-service.ts +178 -0
- package/src/session/index.ts +26 -0
- package/src/session/secret-vault.ts +183 -0
- package/src/session/session-store.ts +339 -0
- package/src/session/types.ts +100 -0
- package/src/types/blocks.ts +56 -0
- package/src/types/context.ts +54 -0
- package/src/types/errors.ts +359 -0
- package/src/types/index.ts +34 -0
- package/src/types/provider.ts +58 -0
- package/src/types/tool.ts +39 -0
- package/src/utils/error.ts +3 -0
- package/src/utils/fs.ts +185 -0
- package/src/utils/image-resize.ts +76 -0
- package/src/utils/ssrf-guard.ts +133 -0
- package/src/utils/ulid.ts +72 -0
- package/src/utils/version-check.ts +59 -0
- package/tests/agent-loop.test.ts +490 -0
- package/tests/audit-log.test.ts +199 -0
- package/tests/auth-service.test.ts +170 -0
- package/tests/blocks.test.ts +79 -0
- package/tests/catalog-parser.test.ts +174 -0
- package/tests/compactor.test.ts +180 -0
- package/tests/container.test.ts +224 -0
- package/tests/conversation-state.test.ts +75 -0
- package/tests/errors.test.ts +429 -0
- package/tests/events-v021.test.ts +60 -0
- package/tests/events-v022.test.ts +75 -0
- package/tests/events.test.ts +340 -0
- package/tests/fixtures/large-image.png +0 -0
- package/tests/fixtures/small-image.png +0 -0
- package/tests/fs-utils.test.ts +164 -0
- package/tests/image-resize.test.ts +51 -0
- package/tests/output-serializer.test.ts +79 -0
- package/tests/path-resolver.test.ts +91 -0
- package/tests/permission-policy.test.ts +174 -0
- package/tests/pipeline.test.ts +193 -0
- package/tests/provider-factory.test.ts +245 -0
- package/tests/provider-runner.test.ts +535 -0
- package/tests/retry-policy.test.ts +104 -0
- package/tests/run-controller.test.ts +115 -0
- package/tests/sanity.test.ts +26 -0
- package/tests/schema-validator.test.ts +109 -0
- package/tests/secret-scrubber.test.ts +133 -0
- package/tests/secret-vault.test.ts +130 -0
- package/tests/session-store.test.ts +429 -0
- package/tests/ssrf-guard.test.ts +112 -0
- package/tests/system-prompt-builder.test.ts +116 -0
- package/tests/token-counter.test.ts +163 -0
- package/tests/tokens.test.ts +42 -0
- package/tests/tool-executor.test.ts +452 -0
- package/tests/tool-registry.test.ts +143 -0
- package/tests/tracer.test.ts +32 -0
- package/tests/ulid.test.ts +53 -0
- package/tests/version-check.test.ts +57 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +16 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { RunController } from '../src/kernel/run-controller.js';
|
|
3
|
+
|
|
4
|
+
describe('RunController', () => {
|
|
5
|
+
describe('signal', () => {
|
|
6
|
+
it('starts not aborted', () => {
|
|
7
|
+
const rc = new RunController();
|
|
8
|
+
expect(rc.aborted).toBe(false);
|
|
9
|
+
expect(rc.signal.aborted).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('abort', () => {
|
|
14
|
+
it('marks signal as aborted', () => {
|
|
15
|
+
const rc = new RunController();
|
|
16
|
+
rc.abort();
|
|
17
|
+
expect(rc.aborted).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('abort is idempotent', () => {
|
|
21
|
+
const rc = new RunController();
|
|
22
|
+
rc.abort('reason1');
|
|
23
|
+
rc.abort('reason2');
|
|
24
|
+
expect(rc.signal.reason).toBe('reason1');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('onAbort hooks', () => {
|
|
29
|
+
it('fires hooks on abort in LIFO order', async () => {
|
|
30
|
+
const rc = new RunController();
|
|
31
|
+
const order: string[] = [];
|
|
32
|
+
rc.onAbort(() => { order.push('first'); });
|
|
33
|
+
rc.onAbort(() => { order.push('second'); });
|
|
34
|
+
rc.abort();
|
|
35
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
36
|
+
expect(order).toEqual(['second', 'first']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns unsubscribe that removes the hook', async () => {
|
|
40
|
+
const rc = new RunController();
|
|
41
|
+
let fired = false;
|
|
42
|
+
const unsub = rc.onAbort(() => { fired = true; });
|
|
43
|
+
unsub();
|
|
44
|
+
rc.abort();
|
|
45
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
46
|
+
expect(fired).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('hook errors are caught and sent to errorSink', async () => {
|
|
50
|
+
const errors: string[] = [];
|
|
51
|
+
const rc = new RunController({
|
|
52
|
+
errorSink: (err) => { errors.push(String(err)); },
|
|
53
|
+
});
|
|
54
|
+
rc.onAbort(() => { throw new Error('hook-fail'); });
|
|
55
|
+
rc.onAbort(() => { });
|
|
56
|
+
rc.abort();
|
|
57
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
58
|
+
expect(errors).toContain('Error: hook-fail');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('dispose', () => {
|
|
63
|
+
it('fires hooks on normal dispose', async () => {
|
|
64
|
+
const rc = new RunController();
|
|
65
|
+
let fired = false;
|
|
66
|
+
rc.onAbort(() => { fired = true; });
|
|
67
|
+
await rc.dispose();
|
|
68
|
+
expect(fired).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('dispose is idempotent', async () => {
|
|
72
|
+
const rc = new RunController();
|
|
73
|
+
let count = 0;
|
|
74
|
+
rc.onAbort(() => { count++; });
|
|
75
|
+
await rc.dispose();
|
|
76
|
+
await rc.dispose();
|
|
77
|
+
expect(count).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('abort then dispose fires hooks only once', async () => {
|
|
81
|
+
const rc = new RunController();
|
|
82
|
+
let count = 0;
|
|
83
|
+
rc.onAbort(() => { count++; });
|
|
84
|
+
rc.abort();
|
|
85
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
86
|
+
await rc.dispose();
|
|
87
|
+
expect(count).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('parent signal', () => {
|
|
92
|
+
it('aborts when parent aborts', () => {
|
|
93
|
+
const parent = new AbortController();
|
|
94
|
+
const rc = new RunController({ parentSignal: parent.signal });
|
|
95
|
+
parent.abort('parent-reason');
|
|
96
|
+
expect(rc.aborted).toBe(true);
|
|
97
|
+
expect(rc.signal.reason).toBe('parent-reason');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('already-aborted parent aborts immediately', () => {
|
|
101
|
+
const parent = new AbortController();
|
|
102
|
+
parent.abort('early');
|
|
103
|
+
const rc = new RunController({ parentSignal: parent.signal });
|
|
104
|
+
expect(rc.aborted).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('removes parent listener on dispose', async () => {
|
|
108
|
+
const parent = new AbortController();
|
|
109
|
+
const rc = new RunController({ parentSignal: parent.signal });
|
|
110
|
+
await rc.dispose();
|
|
111
|
+
parent.abort('late');
|
|
112
|
+
expect(rc.aborted).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { FLOWCODEX_VERSION, FLOWCODEX_NAME, FlowCodexError, ERROR_CODES } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
describe('sanity', () => {
|
|
7
|
+
it('FLOWCODEX_VERSION matches package.json', () => {
|
|
8
|
+
const pkgPath = fileURLToPath(new URL('../../../package.json', import.meta.url));
|
|
9
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string };
|
|
10
|
+
expect(FLOWCODEX_VERSION).toBe(pkg.version);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should export name', () => {
|
|
14
|
+
expect(FLOWCODEX_NAME).toBe('flowcodex');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should export FlowCodexError', () => {
|
|
18
|
+
const err = new FlowCodexError({
|
|
19
|
+
message: 'test',
|
|
20
|
+
code: ERROR_CODES.UNKNOWN,
|
|
21
|
+
subsystem: 'general',
|
|
22
|
+
});
|
|
23
|
+
expect(err).toBeInstanceOf(Error);
|
|
24
|
+
expect(err.code).toBe('UNKNOWN');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { validateAgainstSchema } from '../src/execution/schema-validator.js';
|
|
3
|
+
|
|
4
|
+
describe('schema-validator', () => {
|
|
5
|
+
it('passes when no schema', () => {
|
|
6
|
+
const result = validateAgainstSchema({ foo: 'bar' }, undefined);
|
|
7
|
+
expect(result.ok).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('passes when empty schema', () => {
|
|
11
|
+
const result = validateAgainstSchema({ foo: 'bar' }, {});
|
|
12
|
+
expect(result.ok).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('checks required fields', () => {
|
|
16
|
+
const schema = {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { name: { type: 'string' }, age: { type: 'integer' } },
|
|
19
|
+
required: ['name', 'age'],
|
|
20
|
+
};
|
|
21
|
+
const result = validateAgainstSchema({ name: 'foo' }, schema);
|
|
22
|
+
expect(result.ok).toBe(false);
|
|
23
|
+
expect(result.errors.some((e) => e.path === 'age')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('checks types', () => {
|
|
27
|
+
const schema = {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: { name: { type: 'string' } },
|
|
30
|
+
required: ['name'],
|
|
31
|
+
};
|
|
32
|
+
const result = validateAgainstSchema({ name: 123 }, schema);
|
|
33
|
+
expect(result.ok).toBe(false);
|
|
34
|
+
expect(result.errors[0]?.message).toContain('expected type "string"');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('checks integer type', () => {
|
|
38
|
+
const schema = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: { count: { type: 'integer' } },
|
|
41
|
+
required: ['count'],
|
|
42
|
+
};
|
|
43
|
+
expect(validateAgainstSchema({ count: 5 }, schema).ok).toBe(true);
|
|
44
|
+
expect(validateAgainstSchema({ count: 5.5 }, schema).ok).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('checks enum values', () => {
|
|
48
|
+
const schema = {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
status: { type: 'string', enum: ['pending', 'done'] },
|
|
52
|
+
},
|
|
53
|
+
required: ['status'],
|
|
54
|
+
};
|
|
55
|
+
expect(validateAgainstSchema({ status: 'pending' }, schema).ok).toBe(true);
|
|
56
|
+
expect(validateAgainstSchema({ status: 'unknown' }, schema).ok).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('validates array items', () => {
|
|
60
|
+
const schema = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
items: {
|
|
64
|
+
type: 'array',
|
|
65
|
+
items: { type: 'string' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['items'],
|
|
69
|
+
};
|
|
70
|
+
expect(validateAgainstSchema({ items: ['a', 'b'] }, schema).ok).toBe(true);
|
|
71
|
+
expect(validateAgainstSchema({ items: ['a', 123] }, schema).ok).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('passes valid complex input', () => {
|
|
75
|
+
const schema = {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: {
|
|
78
|
+
name: { type: 'string' },
|
|
79
|
+
count: { type: 'integer' },
|
|
80
|
+
active: { type: 'boolean' },
|
|
81
|
+
},
|
|
82
|
+
required: ['name', 'count', 'active'],
|
|
83
|
+
};
|
|
84
|
+
const result = validateAgainstSchema({ name: 'test', count: 10, active: true }, schema);
|
|
85
|
+
expect(result.ok).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('accepts extra properties not in schema', () => {
|
|
89
|
+
const schema = {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: { name: { type: 'string' } },
|
|
92
|
+
required: ['name'],
|
|
93
|
+
};
|
|
94
|
+
const result = validateAgainstSchema({ name: 'foo', extra: 123 }, schema);
|
|
95
|
+
expect(result.ok).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects non-object when object expected', () => {
|
|
99
|
+
const schema = { type: 'object' };
|
|
100
|
+
const result = validateAgainstSchema('not an object', schema);
|
|
101
|
+
expect(result.ok).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects non-array when array expected', () => {
|
|
105
|
+
const schema = { type: 'array' };
|
|
106
|
+
const result = validateAgainstSchema('not an array', schema);
|
|
107
|
+
expect(result.ok).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { DefaultSecretScrubber } from '../src/infrastructure/secret-scrubber.js';
|
|
3
|
+
|
|
4
|
+
describe('DefaultSecretScrubber', () => {
|
|
5
|
+
const scrubber = new DefaultSecretScrubber();
|
|
6
|
+
|
|
7
|
+
describe('scrub', () => {
|
|
8
|
+
it('returns clean text unchanged', () => {
|
|
9
|
+
expect(scrubber.scrub('hello world')).toBe('hello world');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('redacts Anthropic API key', () => {
|
|
13
|
+
const text = 'The key is sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
|
14
|
+
const result = scrubber.scrub(text);
|
|
15
|
+
expect(result).toContain('[REDACTED:anthropic_key]');
|
|
16
|
+
expect(result).not.toContain('sk-ant-');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('redacts OpenAI API key', () => {
|
|
20
|
+
const text = 'key: sk-xxxxxxxxxxxxxxxxxxxxxxxx';
|
|
21
|
+
const result = scrubber.scrub(text);
|
|
22
|
+
expect(result).toContain('[REDACTED:openai_key]');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('redacts GitHub PAT', () => {
|
|
26
|
+
const text = 'token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
|
27
|
+
const result = scrubber.scrub(text);
|
|
28
|
+
expect(result).toContain('[REDACTED:github_pat]');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('redacts AWS access key', () => {
|
|
32
|
+
const text = 'aws key: AKIAIOSFODNN7EXAMPLE';
|
|
33
|
+
const result = scrubber.scrub(text);
|
|
34
|
+
expect(result).toContain('[REDACTED:aws_access_key]');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('redacts JWT', () => {
|
|
38
|
+
const text = 'jwt: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
|
39
|
+
const result = scrubber.scrub(text);
|
|
40
|
+
expect(result).toContain('[REDACTED:jwt]');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('redacts Bearer token', () => {
|
|
44
|
+
const text = 'Authorization: Bearer dGhpcy1pcy1hLXZlcnktbG9uZy1iZWFyZXItdG9rZW4tZm9yLXRlc3Rpbmc';
|
|
45
|
+
const result = scrubber.scrub(text);
|
|
46
|
+
expect(result).toContain('[REDACTED:bearer_token]');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('redacts private key block', () => {
|
|
50
|
+
const text = '-----BEGIN RSA PRIVATE KEY-----\nMIIBxQIBAAJBALW0g1qD\n-----END RSA PRIVATE KEY-----';
|
|
51
|
+
const result = scrubber.scrub(text);
|
|
52
|
+
expect(result).toContain('[REDACTED:private_key]');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('redacts MongoDB URI', () => {
|
|
56
|
+
const text = 'mongodb://user:pass@cluster.mongodb.net/db';
|
|
57
|
+
const result = scrubber.scrub(text);
|
|
58
|
+
expect(result).toContain('[REDACTED:mongodb_uri]');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('redacts PostgreSQL URI', () => {
|
|
62
|
+
const text = 'postgres://user:password@localhost:5432/db';
|
|
63
|
+
const result = scrubber.scrub(text);
|
|
64
|
+
expect(result).toContain('[REDACTED:postgres_uri]');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('redacts Redis URI', () => {
|
|
68
|
+
const text = 'redis://:password@localhost:6379';
|
|
69
|
+
const result = scrubber.scrub(text);
|
|
70
|
+
expect(result).toContain('[REDACTED:redis_uri]');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('redacts high-entropy env var', () => {
|
|
74
|
+
const text = 'API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
|
75
|
+
const result = scrubber.scrub(text);
|
|
76
|
+
expect(result).toContain('[REDACTED:');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('hasCredentialAnchors (fast path)', () => {
|
|
81
|
+
it('returns false for plain text', () => {
|
|
82
|
+
expect(scrubber.hasCredentialAnchors('just a normal log line')).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns true for text with anchor', () => {
|
|
86
|
+
expect(scrubber.hasCredentialAnchors('key is sk-ant-xxx')).toBe(true);
|
|
87
|
+
expect(scrubber.hasCredentialAnchors('ghp_token_here')).toBe(true);
|
|
88
|
+
expect(scrubber.hasCredentialAnchors('AKIA...')).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('scrubObject', () => {
|
|
93
|
+
it('scrubs strings in nested objects', () => {
|
|
94
|
+
const obj = {
|
|
95
|
+
safe: 'hello',
|
|
96
|
+
key: 'sk-xxxxxxxxxxxxxxxxxxxxxxxx',
|
|
97
|
+
nested: {
|
|
98
|
+
token: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const result = scrubber.scrubObject(obj);
|
|
102
|
+
expect(result.safe).toBe('hello');
|
|
103
|
+
expect(result.key).toContain('[REDACTED:');
|
|
104
|
+
expect(result.nested.token).toContain('[REDACTED:');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('scrubs strings in arrays', () => {
|
|
108
|
+
const arr = ['safe text', 'sk-xxxxxxxxxxxxxxxxxxxxxxxx'];
|
|
109
|
+
const result = scrubber.scrubObject(arr);
|
|
110
|
+
expect(result[0]).toBe('safe text');
|
|
111
|
+
expect(result[1]).toContain('[REDACTED:');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('handles null and undefined', () => {
|
|
115
|
+
expect(scrubber.scrubObject(null)).toBeNull();
|
|
116
|
+
expect(scrubber.scrubObject(undefined)).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('handles numbers and booleans', () => {
|
|
120
|
+
expect(scrubber.scrubObject(42)).toBe(42);
|
|
121
|
+
expect(scrubber.scrubObject(true)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('multiple secrets in one text', () => {
|
|
126
|
+
it('redacts multiple different secrets', () => {
|
|
127
|
+
const text = 'keys: sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxx and ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx';
|
|
128
|
+
const result = scrubber.scrub(text);
|
|
129
|
+
expect(result).toContain('[REDACTED:anthropic_key]');
|
|
130
|
+
expect(result).toContain('[REDACTED:github_pat]');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { promises as fsp } from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import { DefaultSecretVault, ENCRYPTED_PREFIX, isSecretField } from '../src/session/secret-vault.js';
|
|
6
|
+
import * as fsSync from 'node:fs';
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let keyFile: string;
|
|
10
|
+
|
|
11
|
+
describe('DefaultSecretVault', () => {
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-vault-'));
|
|
14
|
+
keyFile = path.join(tmpDir, '.key');
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('encrypts and decrypts correctly', () => {
|
|
21
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
22
|
+
const plaintext = 'sk-ant-api03-my-secret-key';
|
|
23
|
+
const encrypted = vault.encrypt(plaintext);
|
|
24
|
+
expect(encrypted.startsWith(ENCRYPTED_PREFIX)).toBe(true);
|
|
25
|
+
expect(encrypted).not.toContain(plaintext);
|
|
26
|
+
const decrypted = vault.decrypt(encrypted);
|
|
27
|
+
expect(decrypted).toBe(plaintext);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('is idempotent on encrypted values', () => {
|
|
31
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
32
|
+
const encrypted = vault.encrypt('hello');
|
|
33
|
+
expect(vault.encrypt(encrypted)).toBe(encrypted);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('decrypt returns plaintext for unencrypted values', () => {
|
|
37
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
38
|
+
expect(vault.decrypt('plain-value')).toBe('plain-value');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('isEncrypted detects the prefix', () => {
|
|
42
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
43
|
+
expect(vault.isEncrypted('enc:v1:abc:def:ghi')).toBe(true);
|
|
44
|
+
expect(vault.isEncrypted('plaintext')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('creates key file if missing', () => {
|
|
48
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
49
|
+
vault.encrypt('test');
|
|
50
|
+
const stat = fsSync.statSync(keyFile);
|
|
51
|
+
expect(stat.size).toBe(32);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reuses existing key file', () => {
|
|
55
|
+
const vault1 = new DefaultSecretVault({ keyFile });
|
|
56
|
+
const encrypted = vault1.encrypt('secret');
|
|
57
|
+
|
|
58
|
+
const vault2 = new DefaultSecretVault({ keyFile });
|
|
59
|
+
const decrypted = vault2.decrypt(encrypted);
|
|
60
|
+
expect(decrypted).toBe('secret');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('different key files produce different ciphertexts', () => {
|
|
64
|
+
const keyFile2 = path.join(tmpDir, '.key2');
|
|
65
|
+
const vault1 = new DefaultSecretVault({ keyFile });
|
|
66
|
+
const vault2 = new DefaultSecretVault({ keyFile: keyFile2 });
|
|
67
|
+
const enc1 = vault1.encrypt('same-secret');
|
|
68
|
+
const enc2 = vault2.encrypt('same-secret');
|
|
69
|
+
expect(enc1).not.toBe(enc2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('throws on malformed encrypted value', () => {
|
|
73
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
74
|
+
expect(() => vault.decrypt('enc:v1:malformed')).toThrow('malformed');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('throws on tampered ciphertext (auth tag mismatch)', () => {
|
|
78
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
79
|
+
const encrypted = vault.encrypt('secret-data');
|
|
80
|
+
const tampered = encrypted.slice(0, -4) + 'AAAA';
|
|
81
|
+
expect(() => vault.decrypt(tampered)).toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('each encryption uses a unique IV', () => {
|
|
85
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
86
|
+
const enc1 = vault.encrypt('same-input');
|
|
87
|
+
const enc2 = vault.encrypt('same-input');
|
|
88
|
+
expect(enc1).not.toBe(enc2);
|
|
89
|
+
expect(vault.decrypt(enc1)).toBe('same-input');
|
|
90
|
+
expect(vault.decrypt(enc2)).toBe('same-input');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('handles empty string', () => {
|
|
94
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
95
|
+
const encrypted = vault.encrypt('');
|
|
96
|
+
expect(vault.decrypt(encrypted)).toBe('');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('handles unicode', () => {
|
|
100
|
+
const vault = new DefaultSecretVault({ keyFile });
|
|
101
|
+
const plaintext = 'héllo wörld 日本語';
|
|
102
|
+
const encrypted = vault.encrypt(plaintext);
|
|
103
|
+
expect(vault.decrypt(encrypted)).toBe(plaintext);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('isSecretField', () => {
|
|
108
|
+
it('detects apiKey', () => {
|
|
109
|
+
expect(isSecretField('apiKey')).toBe(true);
|
|
110
|
+
expect(isSecretField('api_key')).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('detects token/password/secret', () => {
|
|
114
|
+
expect(isSecretField('authToken')).toBe(true);
|
|
115
|
+
expect(isSecretField('password')).toBe(true);
|
|
116
|
+
expect(isSecretField('client_secret')).toBe(true);
|
|
117
|
+
expect(isSecretField('accessToken')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not flag publicKey', () => {
|
|
121
|
+
expect(isSecretField('publicKey')).toBe(false);
|
|
122
|
+
expect(isSecretField('public_key')).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('does not flag regular fields', () => {
|
|
126
|
+
expect(isSecretField('name')).toBe(false);
|
|
127
|
+
expect(isSecretField('port')).toBe(false);
|
|
128
|
+
expect(isSecretField('timeout')).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|