diff --git a/Cargo.lock b/Cargo.lock index e4d0447596..3aea7087d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4363,6 +4363,7 @@ dependencies = [ "postgres", "ppt-rs", "prometheus", + "proptest", "prost", "rand 0.10.1", "rdev", @@ -5046,12 +5047,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ + "bit-set", + "bit-vec", "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", + "rusty-fork", + "tempfile", "unarray", ] @@ -5129,6 +5134,12 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -5728,6 +5739,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -6629,7 +6652,7 @@ dependencies = [ "fax", "flate2", "half", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] diff --git a/Cargo.toml b/Cargo.toml index ce29dfdfdb..d89aa3baf0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -300,6 +300,9 @@ filetime = "0.2" # beat without real-time waits. Test-only — the runtime feature set in # `[dependencies]` (`full`) intentionally excludes it. tokio = { version = "1", features = ["test-util"] } +# Property-based testing for the adversarial-input surfaces (command classifier, +# encryption round-trip) — plan.md §6.3. Version already resolved in Cargo.lock. +proptest = "1" [features] default = ["tokenjuice-treesitter"] diff --git a/app/package.json b/app/package.json index bfb54dd2d3..4c2fcc8087 100644 --- a/app/package.json +++ b/app/package.json @@ -137,6 +137,7 @@ "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/d3-force": "^3.0.10", "@types/debug": "^4.1.12", + "@types/jest-axe": "^3.5.9", "@types/node": "^25.0.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", @@ -158,6 +159,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "husky": "^9.1.7", + "jest-axe": "^10.0.0", "jsdom": "^28.0.0", "knip": "^6.3.1", "postcss": "^8.5.6", diff --git a/app/src/components/__tests__/a11y.smoke.test.tsx b/app/src/components/__tests__/a11y.smoke.test.tsx new file mode 100644 index 0000000000..03c3eefa25 --- /dev/null +++ b/app/src/components/__tests__/a11y.smoke.test.tsx @@ -0,0 +1,80 @@ +/** + * Accessibility smoke lane (plan.md §7 — the frontend had zero a11y assertions). + * Renders a few high-traffic, self-contained components and runs axe-core over + * the output, asserting no violations. jsdom can't compute layout, so + * color-contrast is auto-skipped; this catches the structural a11y bugs that + * matter — missing roles/labels, invalid ARIA, unlabelled controls. + * + * Kept deliberately small and provider-light so it stays fast and stable; grow + * it screen-by-screen rather than pulling in the full app shell. + */ +import { configureStore } from '@reduxjs/toolkit'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { Provider } from 'react-redux'; +import { describe, expect, it, vi } from 'vitest'; + +import chatRuntimeReducer, { + type ArtifactSnapshot, + type PendingApproval, + setPendingApprovalForThread, +} from '../../store/chatRuntimeSlice'; +import ApprovalRequestCard from '../chat/ApprovalRequestCard'; +import ArtifactCard from '../chat/ArtifactCard'; + +vi.mock('../../services/artifactDownloadService', () => ({ + saveArtifactViaDialog: vi.fn(), + revealArtifactInFileManager: vi.fn(), +})); +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +async function expectNoViolations(container: HTMLElement) { + const results = await axe(container); + expect(results.violations).toEqual([]); +} + +describe('accessibility smoke', () => { + it('ArtifactCard (ready) has no axe violations', async () => { + const artifact: ArtifactSnapshot = { + artifactId: 'a-1', + kind: 'presentation', + title: 'Quarterly Deck', + status: 'ready', + sizeBytes: 4096, + path: 'a-1/deck.pptx', + updatedAt: 0, + }; + const { container } = render(); + await expectNoViolations(container); + }); + + it('ArtifactCard (failed with error) has no axe violations', async () => { + const artifact: ArtifactSnapshot = { + artifactId: 'a-2', + kind: 'document', + title: 'Report', + status: 'failed', + error: 'producer crashed', + updatedAt: 0, + }; + const { container } = render(); + await expectNoViolations(container); + }); + + it('ApprovalRequestCard has no axe violations', async () => { + const approval: PendingApproval = { + requestId: 'req-1', + toolName: 'shell', + message: 'Run `shell` — shell (18 bytes of arguments)', + command: 'pip show yfinance', + }; + const store = configureStore({ reducer: { chatRuntime: chatRuntimeReducer } }); + store.dispatch(setPendingApprovalForThread({ threadId: 't1', approval })); + const { container } = render( + + + + ); + await expectNoViolations(container); + }); +}); diff --git a/plan.md b/plan.md index 0dcc2aad68..53516ff60e 100644 --- a/plan.md +++ b/plan.md @@ -434,9 +434,22 @@ P0, most P1 "untested" claims were **inaccurate against current `main`**. Verifi the ~20 controller domains with no `tests/` reference (the Phase 0 inventory allowlist to burn down). Approval×turn integration, journey spec, and the Playwright approval mirror need WDIO/PW. -**Phase 4 — new dimensions (ongoing)** -- proptest + cargo-fuzz targets; scoped cargo-mutants; jest-axe lane; migration fixture; - duration report; mock chaos modes + contract tests. +**Phase 4 — new dimensions** — ⏳ partial (PR: `test/phase4-new-dimensions`). Unlike the §4 backlog, +these are genuinely greenfield (the suite had zero of them), so they were built and validated: +- [x] **proptest** (was absent): added as a dev-dep; property suite for the command classifier + + path check (never-panic on arbitrary/unicode input; fail-closed redirect floor). NOTE: an + encryption round-trip property was trialled but dropped — under coverage Argon2id is ~2.5s/case, + so it held the lib-test binary ~60s and deterministically exposed a pre-existing env-var race in + the unrelated `config::schema::load` env-overlay tests; the round-trip stays covered by the + Phase 1 fixed-input tests. +- [x] **Mock chaos modes** (§5.3): `httpFaultRules` gains `mode: "reset"` (connection reset) and + `mode: "malformed"` (non-JSON 200) beyond clean statuses; `scripts/mock-api/chaos.test.mjs` + drives the real server (auto-run by the Phase 0 `test:scripts` job). +- [x] **jest-axe a11y smoke lane** (§7 — the frontend had zero a11y assertions): axe-core over + ArtifactCard + ApprovalRequestCard asserting no violations. +- [ ] Still open (larger tooling / own PRs): cargo-fuzz targets, scoped cargo-mutants on the P0 + zones, criterion micro-benches, migration snapshot fixture, per-suite duration report, + mock↔backend contract test. --- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba9dba292a..6af4d50258 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: '@types/debug': specifier: ^4.1.12 version: 4.1.13 + '@types/jest-axe': + specifier: ^3.5.9 + version: 3.5.9 '@types/node': specifier: ^25.0.10 version: 25.6.0 @@ -283,6 +286,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jest-axe: + specifier: ^10.0.0 + version: 10.0.0 jsdom: specifier: ^28.0.0 version: 28.1.0(@noble/hashes@2.2.0) @@ -888,6 +894,10 @@ packages: resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1765,6 +1775,9 @@ packages: resolution: {integrity: sha512-avtjtIQ019sZW3FklpmNNsQOnYZjCHpnVxgDGElfZb+AaR4AvtHNlxXLJp+iqEfSK+Xok8MJarJqIgCaWcF40Q==} engines: {node: '>= 14'} + '@sinclair/typebox@0.27.10': + resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sinclair/typebox@0.34.49': resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} @@ -2009,6 +2022,12 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/jest-axe@3.5.9': + resolution: {integrity: sha512-z98CzR0yVDalCEuhGXXO4/zN4HHuSebAukXDjTLJyjEAgoUf1H1i+sr7SUB/mz8CRS/03/XChsx0dcLjHkndoQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2460,6 +2479,14 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@3.5.6: + resolution: {integrity: sha512-LEUDjgmdJoA3LqklSTwKYqkjcZ4HKc4ddIYGSAiSkr46NTjzg2L9RNB+lekO9P7Dlpa87+hBtzc2Fzn/+GUWMQ==} + engines: {node: '>=4'} + + axe-core@4.10.2: + resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} + engines: {node: '>=4'} + b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -3029,6 +3056,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + diff@5.2.2: resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} engines: {node: '>=0.3.1'} @@ -4009,10 +4040,26 @@ packages: javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + jest-axe@10.0.0: + resolution: {integrity: sha512-9QR0M7//o5UVRnEUUm68IsGapHrcKGakYy9dKWWMX79LmeUKguDI6DREyljC5I13j78OUmtKLF5My6ccffLFBg==} + engines: {node: '>= 16.0.0'} + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-diff@30.3.0: resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.2.2: + resolution: {integrity: sha512-4DkJ1sDPT+UX2MR7Y3od6KtvRi9Im1ZGLGgdLFLm4lPexbTaCgJW5NN3IOXlQHF7NSHY/VHhflQ+WoKtD/vyCw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + jest-matcher-utils@30.3.0: resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4859,6 +4906,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@30.3.0: resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -6635,6 +6686,10 @@ snapshots: '@types/node': 25.6.0 jest-regex-util: 30.0.1 + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.10 + '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.49 @@ -7329,6 +7384,8 @@ snapshots: - encoding - supports-color + '@sinclair/typebox@0.27.10': {} + '@sinclair/typebox@0.34.49': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -7547,6 +7604,16 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 + '@types/jest-axe@3.5.9': + dependencies: + '@types/jest': 30.0.0 + axe-core: 3.5.6 + + '@types/jest@30.0.0': + dependencies: + expect: 30.3.0 + pretty-format: 30.3.0 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -8215,6 +8282,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@3.5.6: {} + + axe-core@4.10.2: {} + b4a@1.8.0: {} bail@2.0.2: {} @@ -8814,6 +8885,8 @@ snapshots: didyoumean@1.2.2: {} + diff-sequences@29.6.3: {} + diff@5.2.2: {} diff@8.0.4: {} @@ -10018,6 +10091,20 @@ snapshots: javascript-natural-sort@0.7.1: {} + jest-axe@10.0.0: + dependencies: + axe-core: 4.10.2 + chalk: 4.1.2 + jest-matcher-utils: 29.2.2 + lodash.merge: 4.6.2 + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + jest-diff@30.3.0: dependencies: '@jest/diff-sequences': 30.3.0 @@ -10025,6 +10112,15 @@ snapshots: chalk: 4.1.2 pretty-format: 30.3.0 + jest-get-type@29.6.3: {} + + jest-matcher-utils@29.2.2: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + jest-matcher-utils@30.3.0: dependencies: '@jest/get-type': 30.1.0 @@ -11195,6 +11291,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-format@30.3.0: dependencies: '@jest/schemas': 30.0.5 diff --git a/scripts/mock-api/chaos.test.mjs b/scripts/mock-api/chaos.test.mjs new file mode 100644 index 0000000000..7a5af0dc5a --- /dev/null +++ b/scripts/mock-api/chaos.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getMockServerPort, + resetMockBehavior, + setMockBehavior, + startMockServer, + stopMockServer, +} from "./index.mjs"; + +// Chaos-toolkit coverage for the mock backend's httpFaultRules engine +// (plan.md §5.3). Beyond clean HTTP error statuses, authors need to reproduce +// two real outage shapes: a connection reset mid-response, and a 200 with a +// non-JSON body. These tests drive the real server over a socket. + +function base() { + const port = getMockServerPort(); + assert.ok(port, "mock server must be running"); + return `http://127.0.0.1:${port}`; +} + +function setFaultRules(rules) { + setMockBehavior("httpFaultRules", JSON.stringify(rules)); +} + +test.beforeEach(async () => { + await stopMockServer(); + resetMockBehavior(); + await startMockServer(); +}); + +test.afterEach(async () => { + await stopMockServer(); +}); + +test("mode:reset tears down the connection (client sees a network error)", async () => { + setFaultRules([{ contains: "/chaos-reset", mode: "reset" }]); + + await assert.rejects( + fetch(`${base()}/chaos-reset`), + (err) => err instanceof TypeError || /reset|hang up|ECONNRESET|fetch failed/i.test(String(err)), + "a reset rule must surface as a client-side network error, not a clean response", + ); +}); + +test("mode:malformed returns a 200 with a body that is not valid JSON", async () => { + setFaultRules([{ contains: "/chaos-malformed", mode: "malformed" }]); + + const res = await fetch(`${base()}/chaos-malformed`); + assert.equal(res.status, 200, "malformed mode defaults to a 200 status"); + + const text = await res.text(); + assert.ok(text.length > 0, "malformed body must be non-empty"); + assert.throws( + () => JSON.parse(text), + "the malformed body must not parse as JSON (that is the whole point)", + ); +}); + +test("mode:malformed honours a custom status and body override", async () => { + setFaultRules([ + { contains: "/chaos-custom", mode: "malformed", status: 502, body: "upstream said " }, + ]); + + const res = await fetch(`${base()}/chaos-custom`); + assert.equal(res.status, 502); + assert.equal(await res.text(), "upstream said "); +}); + +test("default mode still injects a clean HTTP error status + JSON envelope", async () => { + // Regression guard: adding chaos modes must not change the existing + // status-injection behaviour when no mode is set. + setFaultRules([{ contains: "/chaos-status", status: 503, error: "down for maintenance" }]); + + const res = await fetch(`${base()}/chaos-status`); + assert.equal(res.status, 503); + const parsed = await res.json(); + assert.equal(parsed.success, false); + assert.equal(parsed.error, "down for maintenance"); +}); + +test("a request that matches no fault rule is unaffected", async () => { + setFaultRules([{ contains: "/chaos-reset", mode: "reset" }]); + + const res = await fetch(`${base()}/__admin/health`); + assert.equal(res.status, 200, "unmatched requests must pass through to the real handler"); +}); diff --git a/scripts/mock-api/server.mjs b/scripts/mock-api/server.mjs index 6e8fc99f01..68d55ea99e 100644 --- a/scripts/mock-api/server.mjs +++ b/scripts/mock-api/server.mjs @@ -155,6 +155,34 @@ async function maybeApplyGlobalBehavior(ctx) { for (const rule of rules) { if (!ruleMatches(rule, ctx)) continue; + const mode = typeof rule.mode === "string" ? rule.mode : "status"; + + // Chaos mode "reset": tear down the socket mid-response so the client sees + // a connection reset (ECONNRESET / "socket hang up") rather than a clean + // HTTP status — a real outage shape the status path can't reproduce. + if (mode === "reset") { + console.warn( + `[MockServer] Injected connection reset ${ctx.method} ${ctx.url}`, + ); + ctx.res.socket?.destroy(); + return true; + } + + // Chaos mode "malformed": a 200 carrying a non-JSON body, so the caller's + // JSON parse throws instead of receiving a clean error envelope. + if (mode === "malformed") { + const status = Number(rule.status || 200); + const raw = typeof rule.body === "string" ? rule.body : "<>{"; + console.warn( + `[MockServer] Injected malformed body ${ctx.method} ${ctx.url} -> ${status}`, + ); + setCors(ctx.res); + ctx.res.writeHead(status, { "Content-Type": "application/json" }); + ctx.res.end(raw); + return true; + } + + // Default mode "status": a clean HTTP error status with a JSON body. const status = Number(rule.status || 500); const body = rule.body && typeof rule.body === "object" diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index e93816127d..f211b18cbe 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -526,21 +526,27 @@ impl EnvLookup for HashMapEnv { #[test] fn env_overlay_toggles_agent_tracing_capture_content() { - // Off by default; the opt-in env var enables prompt/reply export. + // Serialize with the sibling env-overlay tests (TEST_ENV_LOCK note at the + // top of the file) so a concurrent test's env mutation can't race in. + let _g = env_lock(); + + // ON by default since #4498 (`default_capture_content() == true` — traces + // without content aren't actionable in Langfuse). This assertion was left + // asserting the pre-#4498 `false` default and is corrected here. let mut cfg = Config::default(); - assert!(!cfg.observability.agent_tracing.capture_content); - cfg.apply_env_overlay_with( - &HashMapEnv::new().with("OPENHUMAN_AGENT_TRACING_CAPTURE_CONTENT", "true"), - ); assert!(cfg.observability.agent_tracing.capture_content); - // An explicit falsy value turns it back off. - let mut cfg = Config::default(); - cfg.observability.agent_tracing.capture_content = true; + // An explicit falsy env value turns it off. cfg.apply_env_overlay_with( &HashMapEnv::new().with("OPENHUMAN_AGENT_TRACING_CAPTURE_CONTENT", "off"), ); assert!(!cfg.observability.agent_tracing.capture_content); + + // A truthy value turns it back on. + cfg.apply_env_overlay_with( + &HashMapEnv::new().with("OPENHUMAN_AGENT_TRACING_CAPTURE_CONTENT", "true"), + ); + assert!(cfg.observability.agent_tracing.capture_content); } #[test] diff --git a/src/openhuman/encryption/core.rs b/src/openhuman/encryption/core.rs index db561466e2..4d0e83574a 100644 --- a/src/openhuman/encryption/core.rs +++ b/src/openhuman/encryption/core.rs @@ -314,4 +314,14 @@ mod tests { let payload = k.encrypt(b"").expect("encrypt empty"); assert_eq!(k.decrypt(&payload).expect("decrypt empty"), b""); } + + // NOTE: an `encrypt_decrypt_round_trips_for_arbitrary_input` proptest was + // trialled here but removed: under coverage instrumentation Argon2id runs + // ~2.5s/case, so a 24-case property held the lib-test binary for ~60s and + // deterministically widened a pre-existing env-var race in the unrelated + // `config::schema::load` env-overlay tests (they mutate process-global env + // without per-test serialization). The round-trip is already covered by the + // fixed-input tests above (round-trip, tamper, KDF determinism); the + // property-based *fuzzing* value lives in the fast, panic-focused + // `security::policy::proptest_tests` instead. } diff --git a/src/openhuman/security/policy/mod.rs b/src/openhuman/security/policy/mod.rs index 906c2c2fe2..a93791d83b 100644 --- a/src/openhuman/security/policy/mod.rs +++ b/src/openhuman/security/policy/mod.rs @@ -20,3 +20,6 @@ use std::path::{Path, PathBuf}; #[cfg(test)] #[path = "policy_tests.rs"] mod tests; + +#[cfg(test)] +mod proptest_tests; diff --git a/src/openhuman/security/policy/proptest_tests.rs b/src/openhuman/security/policy/proptest_tests.rs new file mode 100644 index 0000000000..2f4725d627 --- /dev/null +++ b/src/openhuman/security/policy/proptest_tests.rs @@ -0,0 +1,53 @@ +//! Property-based tests for the autonomy classifier's adversarial-input +//! surfaces (plan.md §6.3 — proptest was previously absent). The command +//! classifier and path checks run on fully untrusted strings from the +//! LLM/user, so the central properties are "never panics on any input" and a +//! handful of fail-closed invariants that must hold for *all* inputs, not just +//! the hand-enumerated cases in `policy_tests.rs`. + +use super::types::{CommandClass, SecurityPolicy}; +use proptest::prelude::*; + +proptest! { + /// The classifier + allowlist must never panic, regardless of shell + /// metacharacters, redirects, pipes, or embedded control characters. + #[test] + fn classifier_never_panics_on_regex_strings(cmd in ".*") { + let p = SecurityPolicy::default(); + let _ = p.classify_command(&cmd); + let _ = p.command_risk_level(&cmd); + let _ = p.is_command_allowed(&cmd); + let _ = p.check_gated_command(&cmd); + } + + /// Same, but over fully arbitrary unicode (includes NUL, newlines, and + /// multibyte scalars the `.*` strategy skips). + #[test] + fn classifier_never_panics_on_arbitrary_unicode(cmd in any::()) { + let p = SecurityPolicy::default(); + let _ = p.classify_command(&cmd); + let _ = p.command_risk_level(&cmd); + let _ = p.is_command_allowed(&cmd); + } + + /// Fail-closed floor: a quote-free command with an appended unquoted + /// redirect always classifies as at least `Write` (`cmd > file` writes + /// `file`), no matter what the base command is. + #[test] + fn appended_redirect_forces_at_least_write(prefix in "[a-z][a-z ]{0,40}") { + let p = SecurityPolicy::default(); + let cmd = format!("{prefix} > out.txt"); + prop_assert!( + p.classify_command(&cmd) >= CommandClass::Write, + "an unquoted redirect must lift the class to >= Write: {cmd:?}" + ); + } + + /// The string-level path check must never panic on arbitrary paths + /// (traversal, NUL bytes, tilde, URL-encoding, unicode). + #[test] + fn path_string_check_never_panics(path in ".*") { + let p = SecurityPolicy::default(); + let _ = p.is_path_string_allowed(&path); + } +}