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);
+ }
+}