Repository Public
1import { describe, expect, it } from 'vitest';2import { seal, unseal, safeEqual } from './crypto.js';3import { hashPassword, hasArgon2, needsRehash, verifyPassword } from './password.js';4import { base32Decode, base32Encode, hotp, otpauthUrl, totpAt, totpCounter, verifyTotp } from './totp.js';5import fs from 'node:fs';6import os from 'node:os';7import path from 'node:path';8import { resolveInRoot, slugify, validateProjectRel, validateRel } from '../paths.js';9import { SecretScrubber, loadConfig } from '../config.js';10import { buildCsp, inlineScriptHashes } from '../static.js';11import { contentDisposition } from '../modules/missions/index.js';12import { STEP_BODY_MAX_CHARS, truncateBody } from '../modules/missions/truncate.js';13import { sanitizeRemote, validateGitUrl } from '../modules/projects/git.js';1415const cost = { argonMemoryKiB: 1024, argonPasses: 2, scryptLogN: 10 };1617describe('TOTP (RFC 6238 / RFC 4226)', () => {18 const secret = Buffer.from('12345678901234567890', 'ascii');19 // RFC 6238 Appendix B, SHA-1 rows (8 digits there; the low 6 digits are the 6-digit code).20 const vectors: [number, string][] = [21 [59, '94287082'], [1111111109, '07081804'], [1111111111, '14050471'],22 [1234567890, '89005924'], [2000000000, '69279037'], [20000000000, '65353130'],23 ];24 it.each(vectors)('t=%d s', (t, code8) => {25 expect(totpAt(secret, t * 1000, 8)).toBe(code8);26 expect(totpAt(secret, t * 1000)).toBe(code8.slice(2));27 });28 it('RFC 4226 HOTP vectors', () => {29 const expected = ['755224', '287082', '359152', '969429', '338314', '254676', '287922', '162583', '399871', '520489'];30 expected.forEach((code, i) => expect(hotp(secret, i)).toBe(code));31 });32 it('accepts ±1 step, rejects ±2, and refuses replays', () => {33 const now = 1_700_000_000_000;34 const c = totpCounter(now);35 expect(verifyTotp(secret, hotp(secret, c), now, null)).toBe(c);36 expect(verifyTotp(secret, hotp(secret, c - 1), now, null)).toBe(c - 1);37 expect(verifyTotp(secret, hotp(secret, c + 1), now, null)).toBe(c + 1);38 expect(verifyTotp(secret, hotp(secret, c - 2), now, null)).toBeNull();39 expect(verifyTotp(secret, hotp(secret, c + 2), now, null)).toBeNull();40 expect(verifyTotp(secret, hotp(secret, c), now, c)).toBeNull(); // same code again41 expect(verifyTotp(secret, hotp(secret, c - 1), now, c)).toBeNull(); // older code after a newer one42 expect(verifyTotp(secret, 'abcdef', now, null)).toBeNull();43 });44 it('base32 round-trips and matches RFC 4648', () => {45 expect(base32Encode(Buffer.from('foobar'))).toBe('MZXW6YTBOI');46 expect(base32Decode('MZXW6YTBOI').toString()).toBe('foobar');47 expect(base32Encode(secret)).toBe('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ');48 expect(otpauthUrl('ABC', 'admin')).toMatch(/^otpauth:\/\/totp\/Agent%20OS:admin\?secret=ABC&issuer=Agent\+OS/);49 });50});5152describe('password hashing', () => {53 it('hashes, verifies and reports parameters', async () => {54 const h = await hashPassword('a long enough password', cost);55 expect(h.startsWith(hasArgon2 ? '$argon2id$v=19$m=1024,t=2,p=1$' : '$scrypt$ln=10,r=8,p=1$')).toBe(true);56 expect(await verifyPassword('a long enough password', h)).toBe(true);57 expect(await verifyPassword('a long enough passwore', h)).toBe(false);58 expect(needsRehash(h, cost)).toBe(false);59 expect(needsRehash(h, { ...cost, argonMemoryKiB: 2048, scryptLogN: 11 })).toBe(true);60 });61 it('scrypt fallback verifies and malformed hashes never throw', async () => {62 const h = await hashPassword('another password 123', cost, true);63 expect(h.startsWith('$scrypt$ln=10,r=8,p=1$')).toBe(true);64 expect(await verifyPassword('another password 123', h)).toBe(true);65 expect(await verifyPassword('nope', h)).toBe(false);66 for (const bad of ['', 'plain', '$argon2id$v=19$m=x$a$b', '$scrypt$ln=99,r=8,p=1$aa$bb', '$md5$x$y$z']) {67 expect(await verifyPassword('x', bad)).toBe(false);68 }69 });70});7172describe('crypto helpers', () => {73 const key = Buffer.alloc(32, 7);74 it('seals with AES-256-GCM bound to its AAD', () => {75 const sealed = seal(key, 'JBSWY3DPEHPK3PXP', 'totp:u1');76 expect(sealed).not.toContain('JBSWY3DPEHPK3PXP');77 expect(unseal(key, sealed, 'totp:u1')).toBe('JBSWY3DPEHPK3PXP');78 expect(() => unseal(key, sealed, 'totp:u2')).toThrow();79 expect(() => unseal(Buffer.alloc(32, 8), sealed, 'totp:u1')).toThrow();80 const parts = sealed.split('.');81 parts[3] = `${parts[3]?.slice(0, -2)}AA`;82 expect(() => unseal(key, parts.join('.'), 'totp:u1')).toThrow();83 });84 it('safeEqual', () => {85 expect(safeEqual('abc', 'abc')).toBe(true);86 expect(safeEqual('abc', 'abd')).toBe(false);87 expect(safeEqual('abc', 'abcd')).toBe(false);88 });89});9091describe('path validation', () => {92 it.each(['../x', 'a/../../b', '/etc/passwd', 'a\0b', '..', 'a/..', '~/x', '\\\\srv\\x', 'C:\\x'])('rejects %j', (p) => {93 expect(() => validateRel(p)).toThrow(/invalid path/);94 });95 it('normalises clean paths', () => {96 expect(validateRel('a//b/./c/')).toBe('a/b/c');97 expect(validateRel('')).toBe('');98 expect(validateRel('..a/b..')).toBe('..a/b..');99 expect(() => validateRel('', { allowEmpty: false })).toThrow();100 });101});102103describe('configuration', () => {104 const prod = {105 NODE_ENV: 'production', AOS_PUBLIC_ORIGIN: 'https://agent.example.org', DATABASE_URL: 'postgres://x/y',106 AOS_SECRET_KEY: 'ab'.repeat(32), AOS_DATA_DIR: '/tmp/aos-none', AOS_WORKSPACE_ROOT: '/tmp/ws',107 };108 it('accepts a complete production configuration with secure cookies', () => {109 const c = loadConfig(prod);110 expect(c.cookieName).toBe('__Host-aos_sid');111 expect(c.secureCookies).toBe(true);112 expect(c.rpId).toBe('agent.example.org');113 expect(c.trustedProxies).toEqual(['127.0.0.1', '::1', '10.67.0.1']);114 expect(c.preAuthCookieName).toBe('__Host-aos_pre');115 expect(c.port).toBe(8640);116 expect(c.workspaceRoot).toBe('/tmp/ws');117 });118 it('model API keys are known as booleans only; their values never serialise', () => {119 const c = loadConfig({ ...prod, ANTHROPIC_API_KEY: 'sk-ant-SECRETVALUE-123456' });120 expect(c.providerKeys).toEqual({ anthropic: true, openai: false });121 expect(JSON.stringify(c)).not.toContain('SECRETVALUE');122 expect(c.secrets.scrub('x sk-ant-SECRETVALUE-123456 y sk-ant-SECRETVALUE-123456')).toBe('x [redacted] y [redacted]');123 expect(new SecretScrubber([undefined, '', 'short']).active).toBe(false);124 });125 it.each([126 ['AOS_SECRET_KEY', ''], ['AOS_SECRET_KEY', 'abcd'], ['DATABASE_URL', ''],127 ['AOS_PUBLIC_ORIGIN', 'http://agent.example.org'], ['AOS_PUBLIC_ORIGIN', 'https://agent.example.org/path'],128 ['AOS_TRUSTED_PROXIES', 'not-an-ip'], ['AOS_TRUSTED_PROXIES', '10.0.0.0/99'],129 ])('refuses production with %s=%j', (k, v) => {130 expect(() => loadConfig({ ...prod, [k]: v })).toThrow(/Invalid configuration/);131 });132 it('never downgrades cookies in production, and only for http://localhost otherwise', () => {133 expect(loadConfig({ ...prod, NODE_ENV: 'development', AOS_PUBLIC_ORIGIN: 'http://localhost:8640' }).cookieName).toBe('aos_sid');134 expect(loadConfig({ ...prod, NODE_ENV: 'development', AOS_PUBLIC_ORIGIN: 'http://example.org' }).cookieName).toBe('__Host-aos_sid');135 expect(() => loadConfig({ ...prod, AOS_PUBLIC_ORIGIN: 'http://localhost:8640' })).toThrow();136 });137});138139describe('CSP', () => {140 const c = loadConfig({ NODE_ENV: 'test', AOS_PUBLIC_ORIGIN: 'https://agent.example.org', AOS_SECRET_KEY: 'ab'.repeat(32), AOS_DATA_DIR: '/tmp/aos-none', AOS_WORKSPACE_ROOT: '/tmp/ws' });141 it('hashes inline scripts only', () => {142 const html = '<script src="/a.js"></script><script>alert(1)</script><script type="application/json">{"a":1}</script><script id="x">self.x=1</script>';143 const hashes = inlineScriptHashes(html);144 expect(hashes).toHaveLength(2);145 expect(hashes[0]).toBe("'sha256-bhHHL3z2vDgxUt0W3dWQOrprscmda2Y5pLsLg4GF+pI='"); // sha256("alert(1)")146 });147 it('never allows unsafe-inline scripts', () => {148 const csp = buildCsp(c, ["'sha256-abc='"]);149 const script = csp.split('; ').find((d) => d.startsWith('script-src')) ?? '';150 expect(script).toBe("script-src 'self' 'wasm-unsafe-eval' 'sha256-abc='");151 expect(script).not.toContain('unsafe-inline');152 expect(csp).toContain("frame-ancestors 'none'");153 expect(csp).toContain("connect-src 'self' wss://agent.example.org");154 expect(csp).toContain("object-src 'none'");155 // Same-origin PDF preview only: no third-party frame source, plugins stay forbidden.156 expect(csp.split('; ').find((d) => d.startsWith('frame-src'))).toBe("frame-src 'self' blob:");157 expect(csp.split('; ').filter((d) => d.startsWith('frame-') || d.startsWith('object-'))).toEqual(["frame-src 'self' blob:", "frame-ancestors 'none'", "object-src 'none'"]);158 });159});160161describe('project path safety', () => {162 it.each(['', '.', 'a/.git', '.git', 'x/.aos/y', '-rf', 'a/-b', 'a\\b', 'a\nb', '../x', '/abs'])('refuses project path %j', (p) => {163 expect(() => validateProjectRel(p)).toThrow(/invalid path/);164 });165 it('resolves inside the root and refuses symlink escapes', () => {166 const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-paths-')));167 const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'aos-out-')));168 try {169 fs.mkdirSync(path.join(root, 'a', 'b'), { recursive: true });170 fs.symlinkSync(outside, path.join(root, 'escape'));171 fs.symlinkSync(path.join(root, 'a'), path.join(root, 'inner'));172 fs.symlinkSync(path.join(root, 'nowhere'), path.join(root, 'dangling'));173 expect(resolveInRoot(root, 'a/b')).toEqual({ abs: path.join(root, 'a', 'b'), exists: true });174 expect(resolveInRoot(root, 'a/new/deep')).toEqual({ abs: path.join(root, 'a', 'new', 'deep'), exists: false });175 expect(resolveInRoot(root, 'inner/b')).toEqual({ abs: path.join(root, 'a', 'b'), exists: true }); // symlink inside the root: fine, realpath176 for (const p of ['escape', 'escape/x', 'dangling', 'dangling/x']) expect(() => resolveInRoot(root, p), p).toThrow(/escapes the workspace root/);177 expect(() => resolveInRoot(path.join(root, 'missing-root'), 'a')).toThrow(/workspace root does not exist/);178 } finally {179 fs.rmSync(root, { recursive: true, force: true });180 fs.rmSync(outside, { recursive: true, force: true });181 }182 });183 it('slugify never yields an option-like or empty name', () => {184 expect(slugify('My Project!')).toBe('my-project');185 expect(slugify('--force')).toBe('force');186 expect(slugify('…')).toBe('project');187 });188});189190describe('git URL whitelist', () => {191 it.each([192 'https://github.com/org/repo.git', 'https://user:tok@git.example.org/x/y', 'ssh://git@host.example:2222/org/repo.git',193 'git@github.com:org/repo.git', 'gitsrv:srv/git/app.git',194 ])('accepts %s', (u) => {195 expect(validateGitUrl(u)).toBe(u);196 });197 it.each([198 '', 'http://github.com/org/repo', 'file:///etc/passwd', '/etc/passwd', '../repo', 'ext::sh -c id', '--upload-pack=touch /tmp/x',199 '-oProxyCommand=id', 'https://host/a b', 'https://host/a\nb', 'git@host:../../x', 'ssh://-oProxyCommand=id/x', 'gitsrv:a;id', 'https://h/$(id)',200 'git://host/repo', 'ftp://host/repo', 'https://host/x`id`',201 ])('refuses %j', (u) => {202 expect(() => validateGitUrl(u)).toThrow(/git URL not allowed/);203 });204 it('credentials are stripped from remotes before they are stored or shown', () => {205 expect(sanitizeRemote('https://user:ghp_SECRET@github.com/org/repo.git')).toBe('https://github.com/org/repo.git');206 expect(sanitizeRemote('git@github.com:org/repo.git')).toBe('git@github.com:org/repo.git');207 expect(sanitizeRemote(null)).toBeNull();208 });209});210211describe('step body truncation', () => {212 it('leaves small bodies untouched', () => {213 const v = { name: 'read_file', input: { path: 'a.txt', lines: [1, 2] }, n: null };214 expect(truncateBody(v)).toEqual({ value: v, truncated: false });215 expect(truncateBody(null)).toEqual({ value: null, truncated: false });216 });217 it('bounds the body, keeps the structure and flags it', () => {218 const big = { name: 'write_file', input: { path: 'a.txt', content: 'x'.repeat(100_000) }, after: 'kept-or-dropped', list: Array.from({ length: 5000 }, (_, i) => `item-${i}`) };219 const out = truncateBody(big);220 expect(out.truncated).toBe(true);221 const json = JSON.stringify(out.value);222 expect(json.length).toBeLessThan(STEP_BODY_MAX_CHARS + 500);223 const v = out.value as { name: string; input: { path: string; content: string } };224 expect(v.name).toBe('write_file');225 expect(v.input.path).toBe('a.txt');226 expect(v.input.content).toMatch(/^x{19000,20000}…\[truncated \d+ chars\]$/);227 });228 it('a huge array of small items and a deep nesting are bounded too', () => {229 const arr = truncateBody(Array.from({ length: 200_000 }, (_, i) => i));230 expect(arr.truncated).toBe(true);231 expect(JSON.stringify(arr.value).length).toBeLessThan(40_000);232 let deep: unknown = 'leaf';233 for (let i = 0; i < 500; i++) deep = { d: deep };234 expect(truncateBody(deep).truncated).toBe(true);235 });236});237238describe('artifact download names', () => {239 it('are attachments with a safe ASCII fallback and an RFC 5987 form', () => {240 expect(contentDisposition('report.txt')).toBe(`attachment; filename="report.txt"; filename*=UTF-8''report.txt`);241 expect(contentDisposition('../../etc/pass"wd\r\nX: y')).toBe(`attachment; filename="pass_wdX__y"; filename*=UTF-8''pass%22wdX%3A%20y`);242 expect(contentDisposition('résumé.html')).toMatch(/^attachment; filename="r_sum_.html"; filename\*=UTF-8''r%C3%A9sum%C3%A9\.html$/);243 expect(contentDisposition('.htaccess')).toContain('filename="_htaccess"');244 expect(contentDisposition('')).toContain('filename="artifact"');245 });246});247