takos-runtime-service 1.0.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 (85) hide show
  1. package/package.json +29 -0
  2. package/src/__tests__/middleware/rate-limit.test.ts +33 -0
  3. package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
  4. package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
  5. package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
  6. package/src/__tests__/routes/cli-proxy.test.ts +72 -0
  7. package/src/__tests__/routes/git-http.test.ts +218 -0
  8. package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
  9. package/src/__tests__/routes/sessions/store.test.ts +72 -0
  10. package/src/__tests__/routes/workspace-scope.test.ts +45 -0
  11. package/src/__tests__/runtime/action-registry.test.ts +208 -0
  12. package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
  13. package/src/__tests__/runtime/actions/executor.test.ts +131 -0
  14. package/src/__tests__/runtime/composite-expression.test.ts +294 -0
  15. package/src/__tests__/runtime/file-parsers.test.ts +129 -0
  16. package/src/__tests__/runtime/logging.test.ts +65 -0
  17. package/src/__tests__/runtime/paths.test.ts +236 -0
  18. package/src/__tests__/runtime/secrets.test.ts +247 -0
  19. package/src/__tests__/runtime/validation.test.ts +516 -0
  20. package/src/__tests__/setup.ts +126 -0
  21. package/src/__tests__/shared/errors.test.ts +117 -0
  22. package/src/__tests__/storage/r2.test.ts +106 -0
  23. package/src/__tests__/utils/audit-log.test.ts +163 -0
  24. package/src/__tests__/utils/error-message.test.ts +38 -0
  25. package/src/__tests__/utils/sandbox-env.test.ts +74 -0
  26. package/src/app.ts +245 -0
  27. package/src/index.ts +1 -0
  28. package/src/middleware/rate-limit.ts +91 -0
  29. package/src/middleware/space-scope.ts +95 -0
  30. package/src/routes/actions/action-types.ts +20 -0
  31. package/src/routes/actions/execution.ts +229 -0
  32. package/src/routes/actions/index.ts +17 -0
  33. package/src/routes/actions/job-lifecycle.ts +242 -0
  34. package/src/routes/actions/job-queries.ts +52 -0
  35. package/src/routes/cli/proxy.ts +105 -0
  36. package/src/routes/git/http.ts +565 -0
  37. package/src/routes/git/init.ts +88 -0
  38. package/src/routes/repos/branches.ts +160 -0
  39. package/src/routes/repos/content.ts +209 -0
  40. package/src/routes/repos/read.ts +130 -0
  41. package/src/routes/repos/repo-validation.ts +136 -0
  42. package/src/routes/repos/write.ts +274 -0
  43. package/src/routes/runtime/exec.ts +147 -0
  44. package/src/routes/runtime/tools.ts +113 -0
  45. package/src/routes/sessions/execution.ts +263 -0
  46. package/src/routes/sessions/files.ts +326 -0
  47. package/src/routes/sessions/session-routes.ts +241 -0
  48. package/src/routes/sessions/session-utils.ts +88 -0
  49. package/src/routes/sessions/snapshot.ts +208 -0
  50. package/src/routes/sessions/storage.ts +329 -0
  51. package/src/runtime/actions/action-registry.ts +450 -0
  52. package/src/runtime/actions/action-result-converter.ts +31 -0
  53. package/src/runtime/actions/builtin/artifacts.ts +292 -0
  54. package/src/runtime/actions/builtin/cache-operations.ts +358 -0
  55. package/src/runtime/actions/builtin/checkout.ts +58 -0
  56. package/src/runtime/actions/builtin/index.ts +5 -0
  57. package/src/runtime/actions/builtin/setup-node.ts +86 -0
  58. package/src/runtime/actions/builtin/tar-parser.ts +175 -0
  59. package/src/runtime/actions/composite-executor.ts +192 -0
  60. package/src/runtime/actions/composite-expression.ts +190 -0
  61. package/src/runtime/actions/executor.ts +578 -0
  62. package/src/runtime/actions/file-parsers.ts +51 -0
  63. package/src/runtime/actions/job-manager.ts +213 -0
  64. package/src/runtime/actions/process-spawner.ts +275 -0
  65. package/src/runtime/actions/secrets.ts +162 -0
  66. package/src/runtime/command.ts +120 -0
  67. package/src/runtime/exec-runner.ts +309 -0
  68. package/src/runtime/git-http-backend.ts +145 -0
  69. package/src/runtime/git.ts +98 -0
  70. package/src/runtime/heartbeat.ts +57 -0
  71. package/src/runtime/logging.ts +26 -0
  72. package/src/runtime/paths.ts +264 -0
  73. package/src/runtime/secure-fs.ts +82 -0
  74. package/src/runtime/tools/network.ts +161 -0
  75. package/src/runtime/tools/worker.ts +335 -0
  76. package/src/runtime/validation.ts +292 -0
  77. package/src/shared/config.ts +149 -0
  78. package/src/shared/errors.ts +65 -0
  79. package/src/shared/temp-id.ts +10 -0
  80. package/src/storage/r2.ts +287 -0
  81. package/src/types/hono.d.ts +23 -0
  82. package/src/utils/audit-log.ts +92 -0
  83. package/src/utils/process-kill.ts +18 -0
  84. package/src/utils/sandbox-env.ts +136 -0
  85. package/src/utils/temp-dir.ts +74 -0
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('../../shared/config.js', () => ({
4
+ MAX_LOG_LINES: 5,
5
+ }));
6
+
7
+ import { pushLog } from '../../runtime/logging.js';
8
+
9
+ describe('pushLog', () => {
10
+ it('pushes a message to the log array', () => {
11
+ const logs: string[] = [];
12
+ pushLog(logs, 'hello');
13
+ expect(logs).toEqual(['hello']);
14
+ });
15
+
16
+ it('pushes multiple messages', () => {
17
+ const logs: string[] = [];
18
+ pushLog(logs, 'first');
19
+ pushLog(logs, 'second');
20
+ expect(logs).toEqual(['first', 'second']);
21
+ });
22
+
23
+ it('truncates individual lines exceeding 10000 chars', () => {
24
+ const logs: string[] = [];
25
+ const longLine = 'x'.repeat(15000);
26
+ pushLog(logs, longLine);
27
+ expect(logs[0]).toHaveLength(10000 + '...[truncated]'.length);
28
+ expect(logs[0].endsWith('...[truncated]')).toBe(true);
29
+ });
30
+
31
+ it('stops appending after MAX_LOG_LINES and adds truncation notice', () => {
32
+ const logs: string[] = [];
33
+ // MAX_LOG_LINES is mocked to 5
34
+ for (let i = 0; i < 10; i++) {
35
+ pushLog(logs, `line ${i}`);
36
+ }
37
+ // Should have 5 normal lines + 1 truncation notice = 6 total
38
+ expect(logs).toHaveLength(6);
39
+ expect(logs[5]).toBe('...log truncated');
40
+ });
41
+
42
+ it('adds truncation notice only once', () => {
43
+ const logs: string[] = [];
44
+ for (let i = 0; i < 20; i++) {
45
+ pushLog(logs, `line ${i}`);
46
+ }
47
+ const truncationCount = logs.filter(l => l === '...log truncated').length;
48
+ expect(truncationCount).toBe(1);
49
+ });
50
+
51
+ it('sanitizes message with provided sanitizer', () => {
52
+ const logs: string[] = [];
53
+ const sanitizer = {
54
+ sanitize: (text: string) => text.replace(/secret/g, '***'),
55
+ };
56
+ pushLog(logs, 'my secret value', sanitizer as any);
57
+ expect(logs[0]).toBe('my *** value');
58
+ });
59
+
60
+ it('works without sanitizer', () => {
61
+ const logs: string[] = [];
62
+ pushLog(logs, 'no sanitizer', undefined);
63
+ expect(logs[0]).toBe('no sanitizer');
64
+ });
65
+ });
@@ -0,0 +1,236 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ vi.mock('../../shared/config.js', () => ({
7
+ REPOS_BASE_DIR: '/repos',
8
+ WORKDIR_BASE_DIR: os.tmpdir(),
9
+ }));
10
+
11
+ import {
12
+ isPathWithinBase,
13
+ resolvePathWithin,
14
+ getRepoPath,
15
+ resolveWorkDirPath,
16
+ verifyPathWithinAfterAccess,
17
+ verifyPathWithinBeforeCreate,
18
+ verifyNoSymlinkPathComponents,
19
+ resolveRepoGitPath,
20
+ } from '../../runtime/paths.js';
21
+ import { SymlinkEscapeError, SymlinkNotAllowedError } from '../../shared/errors.js';
22
+
23
+ let tempDir: string;
24
+
25
+ beforeEach(async () => {
26
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'takos-paths-test-'));
27
+ });
28
+
29
+ afterEach(async () => {
30
+ await fs.rm(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // isPathWithinBase
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe('isPathWithinBase', () => {
38
+ it('returns true for a child path', () => {
39
+ expect(isPathWithinBase('/base', '/base/child')).toBe(true);
40
+ });
41
+
42
+ it('returns true for deeply nested path', () => {
43
+ expect(isPathWithinBase('/base', '/base/a/b/c/d')).toBe(true);
44
+ });
45
+
46
+ it('returns true for base itself when allowBase is true', () => {
47
+ expect(isPathWithinBase('/base', '/base', { allowBase: true })).toBe(true);
48
+ });
49
+
50
+ it('returns false for base itself when allowBase is false', () => {
51
+ expect(isPathWithinBase('/base', '/base', { allowBase: false })).toBe(false);
52
+ });
53
+
54
+ it('returns false for path outside base', () => {
55
+ expect(isPathWithinBase('/base', '/other/path')).toBe(false);
56
+ });
57
+
58
+ it('returns false for parent path', () => {
59
+ expect(isPathWithinBase('/base/child', '/base')).toBe(false);
60
+ });
61
+
62
+ it('returns false for traversal path', () => {
63
+ expect(isPathWithinBase('/base', '/base/../other')).toBe(false);
64
+ });
65
+
66
+ it('handles resolveInputs option', () => {
67
+ expect(isPathWithinBase('/tmp', '/tmp/./child', { resolveInputs: true })).toBe(true);
68
+ });
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // resolvePathWithin
73
+ // ---------------------------------------------------------------------------
74
+
75
+ describe('resolvePathWithin', () => {
76
+ it('resolves a valid relative path', () => {
77
+ const result = resolvePathWithin(tempDir, 'subdir/file.txt', 'test');
78
+ expect(result).toBe(path.resolve(tempDir, 'subdir/file.txt'));
79
+ });
80
+
81
+ it('throws on empty target', () => {
82
+ expect(() => resolvePathWithin(tempDir, '', 'test')).toThrow('Invalid test path');
83
+ });
84
+
85
+ it('throws on whitespace-only target', () => {
86
+ expect(() => resolvePathWithin(tempDir, ' ', 'test')).toThrow('Invalid test path');
87
+ });
88
+
89
+ it('throws on absolute path when not allowed', () => {
90
+ expect(() => resolvePathWithin(tempDir, '/etc/passwd', 'test')).toThrow(
91
+ 'Absolute test paths are not allowed',
92
+ );
93
+ });
94
+
95
+ it('throws on path traversal', () => {
96
+ expect(() => resolvePathWithin(tempDir, '../etc/passwd', 'test')).toThrow(
97
+ 'Path traversal not allowed in test',
98
+ );
99
+ });
100
+
101
+ it('allows absolute paths when allowAbsolute is true', () => {
102
+ const absPath = path.join(tempDir, 'allowed');
103
+ const result = resolvePathWithin(tempDir, absPath, 'test', false, true);
104
+ expect(result).toBe(path.resolve(absPath));
105
+ });
106
+
107
+ it('rejects absolute paths outside base even when allowAbsolute is true', () => {
108
+ expect(() => resolvePathWithin(tempDir, '/completely/different', 'test', false, true)).toThrow();
109
+ });
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // getRepoPath
114
+ // ---------------------------------------------------------------------------
115
+
116
+ describe('getRepoPath', () => {
117
+ it('builds correct repo path', () => {
118
+ const result = getRepoPath('workspace1', 'myrepo');
119
+ expect(result).toBe(path.join('/repos', 'workspace1', 'myrepo.git'));
120
+ });
121
+
122
+ it('throws on empty spaceId', () => {
123
+ expect(() => getRepoPath('', 'myrepo')).toThrow('spaceId is required');
124
+ });
125
+
126
+ it('throws on empty repoName', () => {
127
+ expect(() => getRepoPath('ws1', '')).toThrow('repoName is required');
128
+ });
129
+
130
+ it('throws on spaceId with invalid characters', () => {
131
+ expect(() => getRepoPath('ws/../evil', 'repo')).toThrow('invalid characters');
132
+ });
133
+
134
+ it('throws on repoName with invalid characters', () => {
135
+ expect(() => getRepoPath('ws1', 'repo/../../evil')).toThrow('invalid characters');
136
+ });
137
+
138
+ it('throws on spaceId starting with non-alphanumeric', () => {
139
+ expect(() => getRepoPath('_ws', 'repo')).toThrow('must start with an alphanumeric');
140
+ });
141
+
142
+ it('throws on repoName exceeding 128 characters', () => {
143
+ expect(() => getRepoPath('ws1', 'a'.repeat(129))).toThrow('too long');
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // resolveRepoGitPath
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe('resolveRepoGitPath', () => {
152
+ it('accepts valid absolute .git path under REPOS_BASE_DIR', () => {
153
+ const p = '/repos/ws1/myrepo.git';
154
+ expect(resolveRepoGitPath(p)).toBe(path.resolve(p));
155
+ });
156
+
157
+ it('rejects relative path', () => {
158
+ expect(() => resolveRepoGitPath('ws1/myrepo.git')).toThrow('Invalid repoGitPath');
159
+ });
160
+
161
+ it('rejects path not ending in .git', () => {
162
+ expect(() => resolveRepoGitPath('/repos/ws1/myrepo')).toThrow('Invalid repoGitPath');
163
+ });
164
+
165
+ it('rejects path outside REPOS_BASE_DIR', () => {
166
+ expect(() => resolveRepoGitPath('/other/ws1/myrepo.git')).toThrow('Invalid repoGitPath');
167
+ });
168
+
169
+ it('rejects REPOS_BASE_DIR itself as .git', () => {
170
+ // /repos is the base but doesn't end with .git - would already fail
171
+ expect(() => resolveRepoGitPath('/repos')).toThrow('Invalid repoGitPath');
172
+ });
173
+ });
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // verifyPathWithinAfterAccess
177
+ // ---------------------------------------------------------------------------
178
+
179
+ describe('verifyPathWithinAfterAccess', () => {
180
+ it('succeeds for path within base', async () => {
181
+ const child = path.join(tempDir, 'child');
182
+ await fs.mkdir(child, { recursive: true });
183
+ const result = await verifyPathWithinAfterAccess(tempDir, child, 'test');
184
+ expect(result).toBe(await fs.realpath(child));
185
+ });
186
+
187
+ it('throws SymlinkEscapeError for path outside base', async () => {
188
+ const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'outside-'));
189
+ try {
190
+ await expect(
191
+ verifyPathWithinAfterAccess(tempDir, outside, 'test'),
192
+ ).rejects.toThrow(SymlinkEscapeError);
193
+ } finally {
194
+ await fs.rm(outside, { recursive: true, force: true });
195
+ }
196
+ });
197
+ });
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // verifyNoSymlinkPathComponents
201
+ // ---------------------------------------------------------------------------
202
+
203
+ describe('verifyNoSymlinkPathComponents', () => {
204
+ it('succeeds for path with no symlinks', async () => {
205
+ const child = path.join(tempDir, 'a', 'b');
206
+ await fs.mkdir(child, { recursive: true });
207
+ await expect(
208
+ verifyNoSymlinkPathComponents(tempDir, child, 'test'),
209
+ ).resolves.not.toThrow();
210
+ });
211
+
212
+ it('throws SymlinkNotAllowedError when path component is symlink', async () => {
213
+ const realDir = path.join(tempDir, 'real');
214
+ await fs.mkdir(realDir, { recursive: true });
215
+ const symlinkDir = path.join(tempDir, 'sym');
216
+ await fs.symlink(realDir, symlinkDir);
217
+ const target = path.join(symlinkDir, 'file');
218
+
219
+ await expect(
220
+ verifyNoSymlinkPathComponents(tempDir, target, 'test'),
221
+ ).rejects.toThrow(SymlinkNotAllowedError);
222
+ });
223
+
224
+ it('succeeds when path does not exist (ENOENT)', async () => {
225
+ const nonexistent = path.join(tempDir, 'nonexistent', 'deep', 'path');
226
+ await expect(
227
+ verifyNoSymlinkPathComponents(tempDir, nonexistent, 'test'),
228
+ ).resolves.not.toThrow();
229
+ });
230
+
231
+ it('succeeds for base path itself', async () => {
232
+ await expect(
233
+ verifyNoSymlinkPathComponents(tempDir, tempDir, 'test'),
234
+ ).resolves.not.toThrow();
235
+ });
236
+ });
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ SecretsSanitizer,
4
+ mightExposeSecrets,
5
+ shouldBlockForSecretExposure,
6
+ createSecretsSanitizer,
7
+ collectSensitiveEnvValues,
8
+ } from '../../runtime/actions/secrets.js';
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // SecretsSanitizer
12
+ // ---------------------------------------------------------------------------
13
+
14
+ describe('SecretsSanitizer', () => {
15
+ it('sanitizes known secret values', () => {
16
+ const sanitizer = new SecretsSanitizer();
17
+ sanitizer.registerSecrets({ API_KEY: 'my-secret-key' });
18
+
19
+ expect(sanitizer.sanitize('token is my-secret-key here')).toBe('token is *** here');
20
+ });
21
+
22
+ it('sanitizes multiple secrets', () => {
23
+ const sanitizer = new SecretsSanitizer();
24
+ sanitizer.registerSecrets({
25
+ KEY1: 'secret1',
26
+ KEY2: 'secret2',
27
+ });
28
+
29
+ expect(sanitizer.sanitize('secret1 and secret2')).toBe('*** and ***');
30
+ });
31
+
32
+ it('handles empty secrets', () => {
33
+ const sanitizer = new SecretsSanitizer();
34
+ sanitizer.registerSecrets({});
35
+ expect(sanitizer.sanitize('no secrets here')).toBe('no secrets here');
36
+ });
37
+
38
+ it('ignores empty string values in secrets', () => {
39
+ const sanitizer = new SecretsSanitizer();
40
+ sanitizer.registerSecrets({ EMPTY: '' });
41
+ expect(sanitizer.sanitize('some text')).toBe('some text');
42
+ });
43
+
44
+ it('returns input unchanged when no secrets registered', () => {
45
+ const sanitizer = new SecretsSanitizer();
46
+ expect(sanitizer.sanitize('hello world')).toBe('hello world');
47
+ });
48
+
49
+ it('returns empty string unchanged', () => {
50
+ const sanitizer = new SecretsSanitizer();
51
+ sanitizer.registerSecrets({ KEY: 'secret' });
52
+ expect(sanitizer.sanitize('')).toBe('');
53
+ });
54
+
55
+ it('handles regex special characters in secrets', () => {
56
+ const sanitizer = new SecretsSanitizer();
57
+ sanitizer.registerSecrets({ KEY: 'special.chars+and*more' });
58
+
59
+ expect(sanitizer.sanitize('has special.chars+and*more in it')).toBe('has *** in it');
60
+ });
61
+
62
+ it('handles multiple occurrences of same secret', () => {
63
+ const sanitizer = new SecretsSanitizer();
64
+ sanitizer.registerSecrets({ KEY: 'abc' });
65
+
66
+ expect(sanitizer.sanitize('abc abc abc')).toBe('*** *** ***');
67
+ });
68
+
69
+ it('sanitizes logs array', () => {
70
+ const sanitizer = new SecretsSanitizer();
71
+ sanitizer.registerSecrets({ KEY: 'secret' });
72
+
73
+ const logs = ['line with secret', 'clean line', 'another secret'];
74
+ const sanitized = sanitizer.sanitizeLogs(logs);
75
+ expect(sanitized).toEqual(['line with ***', 'clean line', 'another ***']);
76
+ });
77
+
78
+ it('handles long secrets via string replacement fallback', () => {
79
+ const longSecret = 'a'.repeat(5000);
80
+ const sanitizer = new SecretsSanitizer();
81
+ sanitizer.registerSecrets({ KEY: longSecret });
82
+
83
+ const text = `prefix ${longSecret} suffix`;
84
+ expect(sanitizer.sanitize(text)).toBe('prefix *** suffix');
85
+ });
86
+
87
+ it('registerSecretValues adds values', () => {
88
+ const sanitizer = new SecretsSanitizer();
89
+ sanitizer.registerSecretValues(['val1', 'val2']);
90
+
91
+ expect(sanitizer.sanitize('val1 and val2')).toBe('*** and ***');
92
+ });
93
+
94
+ it('clear removes all secrets', () => {
95
+ const sanitizer = new SecretsSanitizer();
96
+ sanitizer.registerSecrets({ KEY: 'secret' });
97
+ sanitizer.clear();
98
+
99
+ expect(sanitizer.sanitize('secret')).toBe('secret');
100
+ });
101
+ });
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // createSecretsSanitizer
105
+ // ---------------------------------------------------------------------------
106
+
107
+ describe('createSecretsSanitizer', () => {
108
+ it('creates sanitizer with secrets', () => {
109
+ const sanitizer = createSecretsSanitizer({ KEY: 'value' });
110
+ expect(sanitizer.sanitize('the value is here')).toBe('the *** is here');
111
+ });
112
+
113
+ it('masks non-empty secrets regardless of length', () => {
114
+ const sanitizer = createSecretsSanitizer({
115
+ ONE: 'x',
116
+ THREE: 'abc',
117
+ EMPTY: '',
118
+ });
119
+
120
+ expect(sanitizer.sanitize('x abc value')).toBe('*** *** value');
121
+ });
122
+
123
+ it('creates sanitizer with extra values', () => {
124
+ const sanitizer = createSecretsSanitizer({}, ['extra']);
125
+ expect(sanitizer.sanitize('extra text')).toBe('*** text');
126
+ });
127
+
128
+ it('creates sanitizer with both secrets and extras', () => {
129
+ const sanitizer = createSecretsSanitizer({ KEY: 'secret' }, ['extra']);
130
+ expect(sanitizer.sanitize('secret and extra')).toBe('*** and ***');
131
+ });
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // mightExposeSecrets
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe('mightExposeSecrets', () => {
139
+ it('detects bare "env" command', () => {
140
+ expect(mightExposeSecrets('env')).not.toBeNull();
141
+ expect(mightExposeSecrets(' env ')).not.toBeNull();
142
+ });
143
+
144
+ it('detects bare "printenv" command', () => {
145
+ expect(mightExposeSecrets('printenv')).not.toBeNull();
146
+ });
147
+
148
+ it('detects "export -p"', () => {
149
+ expect(mightExposeSecrets('export -p')).not.toBeNull();
150
+ });
151
+
152
+ it('returns null for safe commands', () => {
153
+ expect(mightExposeSecrets('echo hello')).toBeNull();
154
+ expect(mightExposeSecrets('npm install')).toBeNull();
155
+ });
156
+
157
+ it('returns null for env with arguments', () => {
158
+ expect(mightExposeSecrets('env VAR=value command')).toBeNull();
159
+ });
160
+
161
+ it('returns null for printenv with arguments', () => {
162
+ expect(mightExposeSecrets('printenv HOME')).toBeNull();
163
+ });
164
+
165
+ it('skips comment lines', () => {
166
+ expect(mightExposeSecrets('# env')).toBeNull();
167
+ });
168
+
169
+ it('skips empty lines', () => {
170
+ expect(mightExposeSecrets('\n\n')).toBeNull();
171
+ });
172
+
173
+ it('detects in multiline command', () => {
174
+ expect(mightExposeSecrets('echo hello\nenv')).not.toBeNull();
175
+ });
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // shouldBlockForSecretExposure
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe('shouldBlockForSecretExposure', () => {
183
+ it('blocks bare env', () => {
184
+ expect(shouldBlockForSecretExposure('env')).toBe(true);
185
+ });
186
+
187
+ it('does not block safe commands', () => {
188
+ expect(shouldBlockForSecretExposure('npm test')).toBe(false);
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // collectSensitiveEnvValues
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe('collectSensitiveEnvValues', () => {
197
+ it('returns empty array for undefined env', () => {
198
+ expect(collectSensitiveEnvValues(undefined)).toEqual([]);
199
+ });
200
+
201
+ it('returns empty array for env with no sensitive keys', () => {
202
+ expect(collectSensitiveEnvValues({ CI: 'true', NODE_ENV: 'test' })).toEqual([]);
203
+ });
204
+
205
+ it('collects TAKOS_TOKEN value', () => {
206
+ expect(collectSensitiveEnvValues({ TAKOS_TOKEN: 'tok123' })).toEqual(['tok123']);
207
+ });
208
+
209
+ it('collects TAKOS_SESSION_ID value', () => {
210
+ expect(collectSensitiveEnvValues({ TAKOS_SESSION_ID: 'sess123' })).toEqual(['sess123']);
211
+ });
212
+
213
+ it('collects keys matching SECRET pattern', () => {
214
+ expect(collectSensitiveEnvValues({ MY_SECRET: 'val' })).toEqual(['val']);
215
+ });
216
+
217
+ it('collects keys matching PASSWORD pattern', () => {
218
+ expect(collectSensitiveEnvValues({ DB_PASSWORD: 'pass' })).toEqual(['pass']);
219
+ });
220
+
221
+ it('collects keys matching TOKEN pattern', () => {
222
+ expect(collectSensitiveEnvValues({ API_TOKEN: 'tok' })).toEqual(['tok']);
223
+ });
224
+
225
+ it('collects keys matching API_KEY pattern', () => {
226
+ expect(collectSensitiveEnvValues({ MY_API_KEY: 'key' })).toEqual(['key']);
227
+ });
228
+
229
+ it('collects keys matching AUTH pattern', () => {
230
+ expect(collectSensitiveEnvValues({ AUTH_HEADER: 'bearer xyz' })).toEqual(['bearer xyz']);
231
+ });
232
+
233
+ it('skips empty values', () => {
234
+ expect(collectSensitiveEnvValues({ MY_SECRET: '' })).toEqual([]);
235
+ });
236
+
237
+ it('collects multiple sensitive values', () => {
238
+ const result = collectSensitiveEnvValues({
239
+ TAKOS_TOKEN: 'tok',
240
+ MY_SECRET: 'sec',
241
+ SAFE_KEY: 'safe',
242
+ });
243
+ expect(result).toContain('tok');
244
+ expect(result).toContain('sec');
245
+ expect(result).not.toContain('safe');
246
+ });
247
+ });