diff --git a/CHANGELOG.md b/CHANGELOG.md index 523992c662..be7864e617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -130,141 +130,6 @@ * split vulnerability scans into critical-fail and high-warn tiers ([#277](https://github.com/Aureliolo/ai-company/issues/277)) ([aba48af](https://github.com/Aureliolo/ai-company/commit/aba48af9d522b2d9d621955984b34abf47d6097a)) -### Maintenance - -* add /worktree skill for parallel worktree management ([#171](https://github.com/Aureliolo/ai-company/issues/171)) ([951e337](https://github.com/Aureliolo/ai-company/commit/951e337ce002e4756bc647d0710f483164a3d338)) -* add design spec context loading to research-link skill ([8ef9685](https://github.com/Aureliolo/ai-company/commit/8ef9685f7fe5164768f206fae68970ba79f4c53f)) -* add post-merge-cleanup skill ([#70](https://github.com/Aureliolo/ai-company/issues/70)) ([f913705](https://github.com/Aureliolo/ai-company/commit/f913705d04be847991854361ae1f0725623e4841)) -* add pre-pr-review skill and update CLAUDE.md ([#103](https://github.com/Aureliolo/ai-company/issues/103)) ([92e9023](https://github.com/Aureliolo/ai-company/commit/92e9023c879384bb3c09cbcef0048f0a118fdcfe)) -* add research-link skill and rename skill files to SKILL.md ([#101](https://github.com/Aureliolo/ai-company/issues/101)) ([651c577](https://github.com/Aureliolo/ai-company/commit/651c57772aa4f2696a41baa6ad89788e71be1f8c)) -* bump aiosqlite from 0.21.0 to 0.22.1 ([#191](https://github.com/Aureliolo/ai-company/issues/191)) ([3274a86](https://github.com/Aureliolo/ai-company/commit/3274a8642e375fa0e51a215bcdf473fcf78c6515)) -* bump pyyaml from 6.0.2 to 6.0.3 in the minor-and-patch group ([#96](https://github.com/Aureliolo/ai-company/issues/96)) ([0338d0c](https://github.com/Aureliolo/ai-company/commit/0338d0c42da16a0366b25c9b860d709ea5e3cc61)) -* bump ruff from 0.15.4 to 0.15.5 ([a49ee46](https://github.com/Aureliolo/ai-company/commit/a49ee464ac475f3780c24902b5331509d0fb8562)) -* fix M0 audit items ([#66](https://github.com/Aureliolo/ai-company/issues/66)) ([c7724b5](https://github.com/Aureliolo/ai-company/commit/c7724b55321a7d2d6b67523f95cfe43cce00f143)) -* **main:** release ai-company 0.1.1 ([#282](https://github.com/Aureliolo/ai-company/issues/282)) ([2f4703d](https://github.com/Aureliolo/ai-company/commit/2f4703d241b0dd39a10c267d95fb260ac9bf98a1)) -* pin setup-uv action to full SHA ([#281](https://github.com/Aureliolo/ai-company/issues/281)) ([4448002](https://github.com/Aureliolo/ai-company/commit/44480022aa613f7898897a74c376b33b0dc41435)) -* post-audit cleanup — PEP 758, loggers, bug fixes, refactoring, tests, hookify rules ([#148](https://github.com/Aureliolo/ai-company/issues/148)) ([c57a6a9](https://github.com/Aureliolo/ai-company/commit/c57a6a9e619ba3339d58df221edf332998a0d1d2)) - -## [0.1.1](https://github.com/Aureliolo/ai-company/compare/ai-company-v0.1.0...ai-company-v0.1.1) (2026-03-10) - - -### Features - -* add autonomy levels and approval timeout policies ([#42](https://github.com/Aureliolo/ai-company/issues/42), [#126](https://github.com/Aureliolo/ai-company/issues/126)) ([#197](https://github.com/Aureliolo/ai-company/issues/197)) ([eecc25a](https://github.com/Aureliolo/ai-company/commit/eecc25a1177f15101d02fb3dc7b95f3d9c023279)) -* add CFO cost optimization service with anomaly detection, reports, and approval decisions ([#186](https://github.com/Aureliolo/ai-company/issues/186)) ([a7fa00b](https://github.com/Aureliolo/ai-company/commit/a7fa00bf9ef113b02aa8ef4bc13ddcb8c61ea972)) -* add code quality toolchain (ruff, mypy, pre-commit, dependabot) ([#63](https://github.com/Aureliolo/ai-company/issues/63)) ([36681a8](https://github.com/Aureliolo/ai-company/commit/36681a8c44d31a2c6e9acc3f55eea7d108c3c36c)) -* add configurable cost tiers and subscription/quota-aware tracking ([#67](https://github.com/Aureliolo/ai-company/issues/67)) ([#185](https://github.com/Aureliolo/ai-company/issues/185)) ([9baedfa](https://github.com/Aureliolo/ai-company/commit/9baedfa5c134c9803065b5c7cd524ff03c66ce4f)) -* add container packaging, Docker Compose, and CI pipeline ([#269](https://github.com/Aureliolo/ai-company/issues/269)) ([435bdfe](https://github.com/Aureliolo/ai-company/commit/435bdfed1e7a5df5767ff31d991021bf3dfd3e12)), closes [#267](https://github.com/Aureliolo/ai-company/issues/267) -* add coordination error taxonomy classification pipeline ([#146](https://github.com/Aureliolo/ai-company/issues/146)) ([#181](https://github.com/Aureliolo/ai-company/issues/181)) ([70c7480](https://github.com/Aureliolo/ai-company/commit/70c748010325824f44f77a798e48241f4703ee0a)) -* add cost-optimized, hierarchical, and auction assignment strategies ([#175](https://github.com/Aureliolo/ai-company/issues/175)) ([ce924fa](https://github.com/Aureliolo/ai-company/commit/ce924faba2fdb10ab430c35f530a750cfd709b30)), closes [#173](https://github.com/Aureliolo/ai-company/issues/173) -* add design specification, license, and project setup ([8669a09](https://github.com/Aureliolo/ai-company/commit/8669a0947d92647bc6a7d7be2a5b334710e5808a)) -* add env var substitution and config file auto-discovery ([#77](https://github.com/Aureliolo/ai-company/issues/77)) ([7f53832](https://github.com/Aureliolo/ai-company/commit/7f53832f9c62210658e91a0e2cf980332deea603)) -* add FastestStrategy routing + vendor-agnostic cleanup ([#140](https://github.com/Aureliolo/ai-company/issues/140)) ([09619cb](https://github.com/Aureliolo/ai-company/commit/09619cb7dc8f7e6bacd5ec4b6beb1b0ca8475149)), closes [#139](https://github.com/Aureliolo/ai-company/issues/139) -* add HR engine and performance tracking ([#45](https://github.com/Aureliolo/ai-company/issues/45), [#47](https://github.com/Aureliolo/ai-company/issues/47)) ([#193](https://github.com/Aureliolo/ai-company/issues/193)) ([2d091ea](https://github.com/Aureliolo/ai-company/commit/2d091eaef9219ff68520b65e6427bcf2ec025fc5)) -* add issue auto-search and resolution verification to PR review skill ([#119](https://github.com/Aureliolo/ai-company/issues/119)) ([deecc39](https://github.com/Aureliolo/ai-company/commit/deecc394c7a90ffc1c69f31eb5c170ebd0cf3250)) -* add memory retrieval, ranking, and context injection pipeline ([#41](https://github.com/Aureliolo/ai-company/issues/41)) ([873b0aa](https://github.com/Aureliolo/ai-company/commit/873b0aaf838ff06e2c2c1bf9785c83447228d81e)) -* add pluggable MemoryBackend protocol with models, config, and events ([#180](https://github.com/Aureliolo/ai-company/issues/180)) ([46cfdd4](https://github.com/Aureliolo/ai-company/commit/46cfdd423aadf2f5f22b3f13c98313855bfbc26f)) -* add pluggable MemoryBackend protocol with models, config, and events ([#32](https://github.com/Aureliolo/ai-company/issues/32)) ([46cfdd4](https://github.com/Aureliolo/ai-company/commit/46cfdd423aadf2f5f22b3f13c98313855bfbc26f)) -* add pluggable PersistenceBackend protocol with SQLite implementation ([#36](https://github.com/Aureliolo/ai-company/issues/36)) ([f753779](https://github.com/Aureliolo/ai-company/commit/f753779bd5628d12ade34d4250db7a768de9a975)) -* add progressive trust and promotion/demotion subsystems ([#43](https://github.com/Aureliolo/ai-company/issues/43), [#49](https://github.com/Aureliolo/ai-company/issues/49)) ([3a87c08](https://github.com/Aureliolo/ai-company/commit/3a87c0836ea95290eafa42ce4cfec4564c1cd36a)) -* add retry handler, rate limiter, and provider resilience ([#100](https://github.com/Aureliolo/ai-company/issues/100)) ([b890545](https://github.com/Aureliolo/ai-company/commit/b8905453fa51a2ca60ffa05f6c4d3598e1d11bc7)) -* add SecOps security agent with rule engine, audit log, and ToolInvoker integration ([#40](https://github.com/Aureliolo/ai-company/issues/40)) ([83b7b6c](https://github.com/Aureliolo/ai-company/commit/83b7b6cd062f16353b19ad0ab8ad41b2d951ac16)) -* add shared org memory and memory consolidation/archival ([#125](https://github.com/Aureliolo/ai-company/issues/125), [#48](https://github.com/Aureliolo/ai-company/issues/48)) ([4a0832b](https://github.com/Aureliolo/ai-company/commit/4a0832b10194232a133c61b6dd6fb12fc579f951)) -* design unified provider interface ([#86](https://github.com/Aureliolo/ai-company/issues/86)) ([3e23d64](https://github.com/Aureliolo/ai-company/commit/3e23d6422b2bd76979ad01af65876bc95928bdcc)) -* expand template presets, rosters, and add inheritance ([#80](https://github.com/Aureliolo/ai-company/issues/80), [#81](https://github.com/Aureliolo/ai-company/issues/81), [#84](https://github.com/Aureliolo/ai-company/issues/84)) ([15a9134](https://github.com/Aureliolo/ai-company/commit/15a91349d7e0305d0d33c9de8eb283fdd2184442)) -* implement agent runtime state vs immutable config split ([#115](https://github.com/Aureliolo/ai-company/issues/115)) ([4cb1ca5](https://github.com/Aureliolo/ai-company/commit/4cb1ca541ccfa5bea44e4b197eedc24e79179c21)) -* implement AgentEngine core orchestrator ([#11](https://github.com/Aureliolo/ai-company/issues/11)) ([#143](https://github.com/Aureliolo/ai-company/issues/143)) ([f2eb73a](https://github.com/Aureliolo/ai-company/commit/f2eb73a1c1864c844b547caf71890354f6031a69)) -* implement basic tool system (registry, invocation, results) ([#15](https://github.com/Aureliolo/ai-company/issues/15)) ([c51068b](https://github.com/Aureliolo/ai-company/commit/c51068b11de77fb15699c203840651044ab482fa)) -* implement built-in file system tools ([#18](https://github.com/Aureliolo/ai-company/issues/18)) ([325ef98](https://github.com/Aureliolo/ai-company/commit/325ef988c2c5312215c7eaf20401d904863c049d)) -* implement communication foundation — message bus, dispatcher, and messenger ([#157](https://github.com/Aureliolo/ai-company/issues/157)) ([8e71bfd](https://github.com/Aureliolo/ai-company/commit/8e71bfd0e3cf84dd36c48f17b933d0554c6f932e)) -* implement company template system with 7 built-in presets ([#85](https://github.com/Aureliolo/ai-company/issues/85)) ([cbf1496](https://github.com/Aureliolo/ai-company/commit/cbf14963be4547749d493e1ba5cc40d75c67a6c5)) -* implement conflict resolution protocol ([#122](https://github.com/Aureliolo/ai-company/issues/122)) ([#166](https://github.com/Aureliolo/ai-company/issues/166)) ([e03f9f2](https://github.com/Aureliolo/ai-company/commit/e03f9f2e09c0493d5ca51a98d83481bd828b9113)) -* implement core entity and role system models ([#69](https://github.com/Aureliolo/ai-company/issues/69)) ([acf9801](https://github.com/Aureliolo/ai-company/commit/acf9801f4b68b1538c07329d9d61771267978bce)) -* implement crash recovery with fail-and-reassign strategy ([#149](https://github.com/Aureliolo/ai-company/issues/149)) ([e6e91ed](https://github.com/Aureliolo/ai-company/commit/e6e91ed3dd19397c3d9d456bbdd8cc2fd8c1cfac)) -* implement engine extensions — Plan-and-Execute loop and call categorization ([#134](https://github.com/Aureliolo/ai-company/issues/134), [#135](https://github.com/Aureliolo/ai-company/issues/135)) ([#159](https://github.com/Aureliolo/ai-company/issues/159)) ([9b2699f](https://github.com/Aureliolo/ai-company/commit/9b2699f3b9b1b07912a6a09e0cd21644f432d744)) -* implement enterprise logging system with structlog ([#73](https://github.com/Aureliolo/ai-company/issues/73)) ([2f787e5](https://github.com/Aureliolo/ai-company/commit/2f787e5b2576a0403f6b86c9daa16dfbbfd2e243)) -* implement graceful shutdown with cooperative timeout strategy ([#130](https://github.com/Aureliolo/ai-company/issues/130)) ([6592515](https://github.com/Aureliolo/ai-company/commit/6592515617742851c1d355422ac40266af3b5127)) -* implement hierarchical delegation and loop prevention ([#12](https://github.com/Aureliolo/ai-company/issues/12), [#17](https://github.com/Aureliolo/ai-company/issues/17)) ([6be60b6](https://github.com/Aureliolo/ai-company/commit/6be60b65dd6cac4f61a023b274353325e1690eae)) -* implement LiteLLM driver and provider registry ([#88](https://github.com/Aureliolo/ai-company/issues/88)) ([ae3f18b](https://github.com/Aureliolo/ai-company/commit/ae3f18b22ca81e99fea84c9f0ccbab8da1ee5605)), closes [#4](https://github.com/Aureliolo/ai-company/issues/4) -* implement LLM decomposition strategy and workspace isolation ([#174](https://github.com/Aureliolo/ai-company/issues/174)) ([aa0eefe](https://github.com/Aureliolo/ai-company/commit/aa0eefe2a1ef3d945adea10979fd4eea45c8c1d7)) -* implement meeting protocol system ([#123](https://github.com/Aureliolo/ai-company/issues/123)) ([ee7caca](https://github.com/Aureliolo/ai-company/commit/ee7cacacad859427c7a2a67f4ce5e72046b15b1b)) -* implement message and communication domain models ([#74](https://github.com/Aureliolo/ai-company/issues/74)) ([560a5d2](https://github.com/Aureliolo/ai-company/commit/560a5d2e29625aeae080babeed1ddb4195dc3743)) -* implement model routing engine ([#99](https://github.com/Aureliolo/ai-company/issues/99)) ([d3c250b](https://github.com/Aureliolo/ai-company/commit/d3c250b8f341fcf0fece7373c1b295e05f83721c)) -* implement parallel agent execution ([#22](https://github.com/Aureliolo/ai-company/issues/22)) ([#161](https://github.com/Aureliolo/ai-company/issues/161)) ([65940b3](https://github.com/Aureliolo/ai-company/commit/65940b3f5bb10692d257fbfda6f4bc692db8aab4)) -* implement per-call cost tracking service ([#7](https://github.com/Aureliolo/ai-company/issues/7)) ([#102](https://github.com/Aureliolo/ai-company/issues/102)) ([c4f1f1c](https://github.com/Aureliolo/ai-company/commit/c4f1f1c9952991fbccc3a44dd4c4b2e65cdd9033)) -* implement personality injection and system prompt construction ([#105](https://github.com/Aureliolo/ai-company/issues/105)) ([934dd85](https://github.com/Aureliolo/ai-company/commit/934dd85c499a922496392865cf35edb1e75166bd)) -* implement single-task execution lifecycle ([#21](https://github.com/Aureliolo/ai-company/issues/21)) ([#144](https://github.com/Aureliolo/ai-company/issues/144)) ([c7e64e4](https://github.com/Aureliolo/ai-company/commit/c7e64e46f85dbd8d2b8b01aad7babd3a00f78bdb)) -* implement subprocess sandbox for tool execution isolation ([#131](https://github.com/Aureliolo/ai-company/issues/131)) ([#153](https://github.com/Aureliolo/ai-company/issues/153)) ([3c8394e](https://github.com/Aureliolo/ai-company/commit/3c8394e905b914de1c81b5d7ed1544920cfb1411)) -* implement task assignment subsystem with pluggable strategies ([#172](https://github.com/Aureliolo/ai-company/issues/172)) ([c7f1b26](https://github.com/Aureliolo/ai-company/commit/c7f1b2628e37821f01605a206d5f36d5ec6f6c95)), closes [#26](https://github.com/Aureliolo/ai-company/issues/26) [#30](https://github.com/Aureliolo/ai-company/issues/30) -* implement task decomposition and routing engine ([#14](https://github.com/Aureliolo/ai-company/issues/14)) ([9c7fb52](https://github.com/Aureliolo/ai-company/commit/9c7fb526e7a469b8fd4a1ee106670b292a24879a)) -* implement Task, Project, Artifact, Budget, and Cost domain models ([#71](https://github.com/Aureliolo/ai-company/issues/71)) ([81eabf1](https://github.com/Aureliolo/ai-company/commit/81eabf1042d30ab67a6e1c0976d0da58b78a9ab9)) -* implement tool permission checking ([#16](https://github.com/Aureliolo/ai-company/issues/16)) ([833c190](https://github.com/Aureliolo/ai-company/commit/833c190de2d886ca5cb516341d50e4fb86bc6879)) -* implement YAML config loader with Pydantic validation ([#59](https://github.com/Aureliolo/ai-company/issues/59)) ([ff3a2ba](https://github.com/Aureliolo/ai-company/commit/ff3a2ba973f915d8d7f71311188d71d1e461285d)) -* implement YAML config loader with Pydantic validation ([#75](https://github.com/Aureliolo/ai-company/issues/75)) ([ff3a2ba](https://github.com/Aureliolo/ai-company/commit/ff3a2ba973f915d8d7f71311188d71d1e461285d)) -* initialize project with uv, hatchling, and src layout ([39005f9](https://github.com/Aureliolo/ai-company/commit/39005f96bc665123fa25ce55121ae8fe25bc8cc3)) -* initialize project with uv, hatchling, and src layout ([#62](https://github.com/Aureliolo/ai-company/issues/62)) ([39005f9](https://github.com/Aureliolo/ai-company/commit/39005f96bc665123fa25ce55121ae8fe25bc8cc3)) -* Litestar REST API, WebSocket feed, and approval queue (M6) ([#189](https://github.com/Aureliolo/ai-company/issues/189)) ([29fcd08](https://github.com/Aureliolo/ai-company/commit/29fcd0851a4790fe9d25626a3d26890ca41908c6)) -* make TokenUsage.total_tokens a computed field ([#118](https://github.com/Aureliolo/ai-company/issues/118)) ([c0bab18](https://github.com/Aureliolo/ai-company/commit/c0bab18e51c6bce227eec7ba112ba3178bd847d1)), closes [#109](https://github.com/Aureliolo/ai-company/issues/109) -* parallel tool execution in ToolInvoker.invoke_all ([#137](https://github.com/Aureliolo/ai-company/issues/137)) ([58517ee](https://github.com/Aureliolo/ai-company/commit/58517ee64a36d764142790640dfb996c9ff75100)) -* testing framework, CI pipeline, and M0 gap fixes ([#64](https://github.com/Aureliolo/ai-company/issues/64)) ([f581749](https://github.com/Aureliolo/ai-company/commit/f581749ae57cb46f4fc687ab0d1f22a492593b64)) -* wire all modules into observability system ([#97](https://github.com/Aureliolo/ai-company/issues/97)) ([f7a0617](https://github.com/Aureliolo/ai-company/commit/f7a0617a2659dcdc6d33447801623a879cf4c60c)) - - -### Bug Fixes - -* address Greptile post-merge review findings from PRs [#170](https://github.com/Aureliolo/ai-company/issues/170)-[#175](https://github.com/Aureliolo/ai-company/issues/175) ([#176](https://github.com/Aureliolo/ai-company/issues/176)) ([c5ca929](https://github.com/Aureliolo/ai-company/commit/c5ca92933a0cbe4b1943528150a22c529fa44f3f)) -* address post-merge review feedback from PRs [#164](https://github.com/Aureliolo/ai-company/issues/164)-[#167](https://github.com/Aureliolo/ai-company/issues/167) ([#170](https://github.com/Aureliolo/ai-company/issues/170)) ([3bf897a](https://github.com/Aureliolo/ai-company/commit/3bf897a6ffde53bc940b2a993e0206d1d0bf2747)), closes [#169](https://github.com/Aureliolo/ai-company/issues/169) -* enforce strict mypy on test files ([#89](https://github.com/Aureliolo/ai-company/issues/89)) ([aeeff8c](https://github.com/Aureliolo/ai-company/commit/aeeff8ca16fdae92ec1b8fc6c8c1bc6161b64e79)) -* harden Docker sandbox, MCP bridge, and code runner ([#50](https://github.com/Aureliolo/ai-company/issues/50), [#53](https://github.com/Aureliolo/ai-company/issues/53)) ([d5e1b6e](https://github.com/Aureliolo/ai-company/commit/d5e1b6ee1915bfb4c3342abd0d0e7aa79b9a1f20)) -* harden git tools security + code quality improvements ([#150](https://github.com/Aureliolo/ai-company/issues/150)) ([000a325](https://github.com/Aureliolo/ai-company/commit/000a325a8a39db623d6ad397ad1d3f922e75e49e)) -* harden subprocess cleanup, env filtering, and shutdown resilience ([#155](https://github.com/Aureliolo/ai-company/issues/155)) ([d1fe1fb](https://github.com/Aureliolo/ai-company/commit/d1fe1fbec2a50980efbc162e4662c373e2d166a3)) -* incorporate post-merge feedback + pre-PR review fixes ([#164](https://github.com/Aureliolo/ai-company/issues/164)) ([c02832a](https://github.com/Aureliolo/ai-company/commit/c02832ac4d67aee9a19adcb4d713342f7f5bc45e)) -* pre-PR review fixes for post-merge findings ([#183](https://github.com/Aureliolo/ai-company/issues/183)) ([26b3108](https://github.com/Aureliolo/ai-company/commit/26b31085e527a477bf2ebbc800929d0da743c6b2)) -* strengthen immutability for BaseTool schema and ToolInvoker boundaries ([#117](https://github.com/Aureliolo/ai-company/issues/117)) ([7e5e861](https://github.com/Aureliolo/ai-company/commit/7e5e86189cf0229106911f4ba0f1238414edb401)) - - -### Performance - -* harden non-inferable principle implementation ([#195](https://github.com/Aureliolo/ai-company/issues/195)) ([02b5f4e](https://github.com/Aureliolo/ai-company/commit/02b5f4e742288fd644212c804395cd751d9ffc27)), closes [#188](https://github.com/Aureliolo/ai-company/issues/188) - - -### Refactoring - -* adopt NotBlankStr across all models ([#108](https://github.com/Aureliolo/ai-company/issues/108)) ([#120](https://github.com/Aureliolo/ai-company/issues/120)) ([ef89b90](https://github.com/Aureliolo/ai-company/commit/ef89b901a86ca795ef1b58fd82c3950dbfd5b0f1)) -* extract _SpendingTotals base class from spending summary models ([#111](https://github.com/Aureliolo/ai-company/issues/111)) ([2f39c1b](https://github.com/Aureliolo/ai-company/commit/2f39c1baf0de8c72911925c93ec94dc193d06916)) -* harden BudgetEnforcer with error handling, validation extraction, and review fixes ([#182](https://github.com/Aureliolo/ai-company/issues/182)) ([c107bf9](https://github.com/Aureliolo/ai-company/commit/c107bf9986b54482d76f4495c9eb199e1e132f8a)) -* harden personality profiles, department validation, and template rendering ([#158](https://github.com/Aureliolo/ai-company/issues/158)) ([10b2299](https://github.com/Aureliolo/ai-company/commit/10b2299989562e05868913ed90aec7e123b4dbf2)) -* pre-PR review improvements for ExecutionLoop + ReAct loop ([#124](https://github.com/Aureliolo/ai-company/issues/124)) ([8dfb3c0](https://github.com/Aureliolo/ai-company/commit/8dfb3c0609ac2e9a7c3582fe7c515757f6cb6aa9)) -* split events.py into per-domain event modules ([#136](https://github.com/Aureliolo/ai-company/issues/136)) ([e9cba89](https://github.com/Aureliolo/ai-company/commit/e9cba896aeb33925bba7c507fcd90729cb20f294)) - - -### Documentation - -* add ADR-001 memory layer evaluation and selection ([#178](https://github.com/Aureliolo/ai-company/issues/178)) ([db3026f](https://github.com/Aureliolo/ai-company/commit/db3026f41ea974bb85992cabb0cec722cba42f85)), closes [#39](https://github.com/Aureliolo/ai-company/issues/39) -* add agent scaling research findings to DESIGN_SPEC ([#145](https://github.com/Aureliolo/ai-company/issues/145)) ([57e487b](https://github.com/Aureliolo/ai-company/commit/57e487b1e029205cf6f733faefc50a29005b6b71)) -* add CLAUDE.md, contributing guide, and dev documentation ([#65](https://github.com/Aureliolo/ai-company/issues/65)) ([55c1025](https://github.com/Aureliolo/ai-company/commit/55c102594428425882193afb80107120c93981e3)), closes [#54](https://github.com/Aureliolo/ai-company/issues/54) -* add crash recovery, sandboxing, analytics, and testing decisions ([#127](https://github.com/Aureliolo/ai-company/issues/127)) ([5c11595](https://github.com/Aureliolo/ai-company/commit/5c11595c87e61f72b0ffbfc004f9cf1c4639faf4)) -* address external review feedback with MVP scope and new protocols ([#128](https://github.com/Aureliolo/ai-company/issues/128)) ([3b30b9a](https://github.com/Aureliolo/ai-company/commit/3b30b9a986a1f977092d5821e65189ed896cb63f)) -* expand design spec with pluggable strategy protocols ([#121](https://github.com/Aureliolo/ai-company/issues/121)) ([6832db6](https://github.com/Aureliolo/ai-company/commit/6832db6e0d8a8295b2b1baf350e02c3f85d95cdd)) -* finalize 23 design decisions (ADR-002) ([#190](https://github.com/Aureliolo/ai-company/issues/190)) ([8c39742](https://github.com/Aureliolo/ai-company/commit/8c39742b23404dc583d87ffa4611825521fb1bfc)) -* update project docs for M2.5 conventions and add docs-consistency review agent ([#114](https://github.com/Aureliolo/ai-company/issues/114)) ([99766ee](https://github.com/Aureliolo/ai-company/commit/99766eee6ed9b0354cfa4d7e8dba7a6846299a74)) - - -### Tests - -* add e2e single agent integration tests ([#24](https://github.com/Aureliolo/ai-company/issues/24)) ([#156](https://github.com/Aureliolo/ai-company/issues/156)) ([f566fb4](https://github.com/Aureliolo/ai-company/commit/f566fb4bf469e119c434691c22f8894e49609a83)) -* add provider adapter integration tests ([#90](https://github.com/Aureliolo/ai-company/issues/90)) ([40a61f4](https://github.com/Aureliolo/ai-company/commit/40a61f48a309d2b08797d1c840ce1d946d255d88)) - - -### CI/CD - -* add Release Please for automated versioning and GitHub Releases ([#278](https://github.com/Aureliolo/ai-company/issues/278)) ([a488758](https://github.com/Aureliolo/ai-company/commit/a4887580a2262bfd84e76c861f0106a13a438fd0)) -* bump actions/checkout from 4 to 6 ([#95](https://github.com/Aureliolo/ai-company/issues/95)) ([1897247](https://github.com/Aureliolo/ai-company/commit/1897247a8bd561715639bf6dac4b136ccace7d75)) -* bump actions/upload-artifact from 4 to 7 ([#94](https://github.com/Aureliolo/ai-company/issues/94)) ([27b1517](https://github.com/Aureliolo/ai-company/commit/27b15177b49357e2d9c051202b97487645cd8da5)) -* harden CI/CD pipeline ([#92](https://github.com/Aureliolo/ai-company/issues/92)) ([ce4693c](https://github.com/Aureliolo/ai-company/commit/ce4693ce859128e90c67beb519291ef7b4acf77e)) -* split vulnerability scans into critical-fail and high-warn tiers ([#277](https://github.com/Aureliolo/ai-company/issues/277)) ([aba48af](https://github.com/Aureliolo/ai-company/commit/aba48af9d522b2d9d621955984b34abf47d6097a)) - - ### Maintenance * add /worktree skill for parallel worktree management ([#171](https://github.com/Aureliolo/ai-company/issues/171)) ([951e337](https://github.com/Aureliolo/ai-company/commit/951e337ce002e4756bc647d0710f483164a3d338)) diff --git a/DESIGN_SPEC.md b/DESIGN_SPEC.md index c72819acb2..ba73d99c2c 100644 --- a/DESIGN_SPEC.md +++ b/DESIGN_SPEC.md @@ -2744,7 +2744,7 @@ Circular inheritance is detected via chain tracking and raises `TemplateInherita | **Docker API** | aiodocker | Async-native Docker API client for `DockerSandbox` backend | | **Tool Integration** | MCP SDK (`mcp`) | Industry standard for LLM-to-tool integration | | **Agent Comms** | A2A Protocol compatible | Future-proof inter-agent communication | -| **Authentication** | PyJWT + argon2-cffi | JWT (HMAC HS256/384/512) for session tokens, Argon2id for password hashing, SHA-256 for API key storage | +| **Authentication** | PyJWT + argon2-cffi | JWT (HMAC HS256/384/512) for session tokens, Argon2id for password hashing, HMAC-SHA256 for API key storage (keyed with server secret) | | **Config Format** | YAML + Pydantic validation | Human-readable config with strict validation | | **CLI** | TBD (future, if needed) | Thin wrapper around the REST API for terminal use. May not be needed — interactive Scalar docs at `/docs/api` and `curl`/`httpie` may suffice | @@ -3172,12 +3172,12 @@ synthorg/ │ │ ├── app.py # Litestar application factory, lifecycle hooks │ │ ├── approval_store.py # In-memory approval queue storage │ │ ├── auth/ # JWT + API key authentication subsystem -│ │ │ ├── config.py # AuthConfig (frozen Pydantic, HMAC algorithm, exclude paths) +│ │ │ ├── config.py # AuthConfig (frozen Pydantic, JWT HMAC algorithm, exclude paths) │ │ │ ├── controller.py # AuthController (setup, login, change-password, me) │ │ │ ├── middleware.py # ApiAuthMiddleware (JWT-first, API key fallback) │ │ │ ├── models.py # User, ApiKey, AuthenticatedUser, AuthMethod │ │ │ ├── secret.py # JWT secret resolution (env var → persistence → auto-generate) -│ │ │ └── service.py # AuthService (Argon2id password hashing, JWT ops, API key hashing) +│ │ │ └── service.py # AuthService (Argon2id password hashing, JWT ops, HMAC-SHA256 API key hashing) │ │ ├── bus_bridge.py # Message-bus → WebSocket bridge │ │ ├── channels.py # WebSocket channel definitions │ │ ├── config.py # API configuration models (ServerConfig, CorsConfig) @@ -3237,6 +3237,7 @@ synthorg/ │ │ ├── ci.yml # Lint + type-check + test (parallel) │ │ ├── docker.yml # Build → scan → push → sign (GHCR) │ │ ├── dependency-review.yml # License allow-list on PRs +│ │ ├── release.yml # Release Please (automated versioning + GitHub Releases) │ │ └── secret-scan.yml # Gitleaks on push/PR + weekly │ ├── actions/ │ │ └── setup-python-uv/ # Composite action: Python + uv install diff --git a/README.md b/README.md index 9e51fad1d9..4bf594b118 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ SynthOrg lets you spin up a synthetic organization staffed entirely by AI agents ### Security -- Authentication — JWT + API key, Argon2id hashing, first-run admin setup +- Authentication — JWT + API key, Argon2id password hashing, HMAC-SHA256 API key hashing, first-run admin setup - SecOps agent — rule engine (soft-allow/hard-deny, fail-closed), audit log, output scanner, risk classifier - Progressive trust — 4 strategies behind `TrustStrategy` protocol - Autonomy levels — 5 tiers, presets, resolver, change strategies diff --git a/src/ai_company/api/auth/config.py b/src/ai_company/api/auth/config.py index 88f9d58661..416f925049 100644 --- a/src/ai_company/api/auth/config.py +++ b/src/ai_company/api/auth/config.py @@ -40,7 +40,9 @@ class AuthConfig(BaseModel): before the first request is served. Attributes: - jwt_secret: HMAC signing key (resolved at startup, repr-hidden). + jwt_secret: HMAC signing key for JWT tokens and API key + hashing (resolved at startup, repr-hidden). Rotating + this invalidates all stored API key hashes. jwt_algorithm: JWT signing algorithm (HMAC family only). jwt_expiry_minutes: Token lifetime in minutes. min_password_length: Minimum password length for setup/change. @@ -52,7 +54,11 @@ class AuthConfig(BaseModel): jwt_secret: str = Field( default="", repr=False, - description="JWT signing secret (resolved at startup)", + description=( + "JWT signing secret (resolved at startup). " + "Also used as the HMAC key for API key hash computation — " + "rotating this secret invalidates all stored API key hashes." + ), ) jwt_algorithm: Literal["HS256", "HS384", "HS512"] = Field( default="HS256", diff --git a/src/ai_company/api/auth/middleware.py b/src/ai_company/api/auth/middleware.py index a3e20bc83e..c9f584fdb7 100644 --- a/src/ai_company/api/auth/middleware.py +++ b/src/ai_company/api/auth/middleware.py @@ -1,6 +1,7 @@ """JWT + API key authentication middleware.""" import hashlib +import hmac as _hmac from datetime import UTC, datetime from typing import TYPE_CHECKING, Any @@ -12,7 +13,7 @@ ) from ai_company.api.auth.models import AuthenticatedUser, AuthMethod -from ai_company.api.auth.service import AuthService +from ai_company.api.auth.service import SecretNotConfiguredError from ai_company.observability import get_logger from ai_company.observability.events.api import ( API_AUTH_FAILED, @@ -23,6 +24,8 @@ from litestar.connection import ASGIConnection from ai_company.api.auth.config import AuthConfig + from ai_company.api.auth.models import ApiKey + from ai_company.api.auth.service import AuthService from ai_company.api.state import AppState logger = get_logger(__name__) @@ -30,13 +33,48 @@ _BEARER_PARTS = 2 +def _validate_auth_header( + connection: ASGIConnection[Any, Any, Any, Any], +) -> str: + """Extract and validate the bearer token from the request. + + Returns: + The bearer token string. + + Raises: + NotAuthorizedException: On missing or invalid header. + """ + path = str(connection.url.path) + auth_header = connection.headers.get("authorization") + if not auth_header: + logger.warning( + API_AUTH_FAILED, + reason="missing_header", + path=path, + ) + raise NotAuthorizedException( + detail="Missing Authorization header", + ) + token = _extract_bearer_token(auth_header) + if token is None: + logger.warning( + API_AUTH_FAILED, + reason="invalid_scheme", + path=path, + ) + raise NotAuthorizedException( + detail="Invalid authorization scheme", + ) + return token + + class ApiAuthMiddleware(AbstractAuthenticationMiddleware): """Authenticate requests via JWT or API key. Reads ``Authorization: Bearer `` from the request. Tokens containing ``.`` are treated exclusively as JWTs. - Tokens without dots are tried as API keys via SHA-256 hash - lookup. + Tokens without dots are tried as API keys via HMAC-SHA256 + hash lookup. Requires ``auth_service``, persistence backend on ``app.state["app_state"]``. @@ -57,44 +95,30 @@ async def authenticate_request( Raises: NotAuthorizedException: If authentication fails. """ - auth_header = connection.headers.get("authorization") - if not auth_header: - logger.warning( - API_AUTH_FAILED, - reason="missing_header", - path=str(connection.url.path), - ) - raise NotAuthorizedException(detail="Missing Authorization header") - - token = _extract_bearer_token(auth_header) - if token is None: - logger.warning( - API_AUTH_FAILED, - reason="invalid_scheme", - path=str(connection.url.path), - ) - raise NotAuthorizedException(detail="Invalid authorization scheme") - + token = _validate_auth_header(connection) app_state = connection.app.state["app_state"] auth_service: AuthService = app_state.auth_service + path = str(connection.url.path) - # Try JWT for tokens with dots; API key otherwise if "." in token: - user = await _try_jwt_auth(token, auth_service, app_state, connection) + user = await _try_jwt_auth( + token, + auth_service, + app_state, + path, + ) if user is not None: return AuthenticationResult(user=user, auth=token) raise NotAuthorizedException(detail="Invalid JWT token") - # API key (no dots in token) - user = await _try_api_key_auth(token, app_state, connection) + user = await _try_api_key_auth( + token, + auth_service, + app_state, + path, + ) if user is not None: return AuthenticationResult(user=user, auth=token) - - logger.warning( - API_AUTH_FAILED, - reason="invalid_credentials", - path=str(connection.url.path), - ) raise NotAuthorizedException(detail="Invalid credentials") @@ -110,14 +134,16 @@ async def _try_jwt_auth( token: str, auth_service: AuthService, app_state: AppState, - connection: ASGIConnection[Any, Any, Any, Any], + path: str, ) -> AuthenticatedUser | None: """Attempt JWT authentication. + Validates the token signature, expiry, and required claims. + Delegates user resolution and ``pwd_sig`` validation to + :func:`_resolve_jwt_user`. + Returns: - Authenticated user on success, or ``None`` if the token is - invalid, the ``sub`` claim is missing, or the user no longer - exists in the database. + Authenticated user on success, or ``None`` on failure. """ try: claims = auth_service.decode_token(token) @@ -127,88 +153,118 @@ async def _try_jwt_auth( reason="jwt_invalid", error_type=type(exc).__qualname__, error=str(exc), - path=str(connection.url.path), + path=path, + ) + return None + except SecretNotConfiguredError: + logger.exception( + API_AUTH_FAILED, + reason="jwt_secret_not_configured", + path=path, ) return None + return await _resolve_jwt_user(claims, app_state, path) + + +async def _resolve_jwt_user( + claims: dict[str, Any], + app_state: AppState, + path: str, +) -> AuthenticatedUser | None: + """Resolve user from JWT claims and validate ``pwd_sig``. + The ``pwd_sig`` is a plain SHA-256 truncation (not HMAC) of + the stored password hash, protected by the JWT signature. + """ user_id = claims.get("sub") if not user_id: - logger.warning( - API_AUTH_FAILED, - reason="jwt_missing_sub", - path=str(connection.url.path), - ) + logger.warning(API_AUTH_FAILED, reason="jwt_missing_sub", path=path) return None - persistence = app_state.persistence - db_user = await persistence.users.get(user_id) + db_user = await app_state.persistence.users.get(user_id) if db_user is None: logger.warning( API_AUTH_FAILED, reason="jwt_user_not_found", user_id=user_id, - path=str(connection.url.path), + path=path, ) return None - expected_sig = hashlib.sha256( - db_user.password_hash.encode(), - ).hexdigest()[:16] - if claims.get("pwd_sig") != expected_sig: + expected_sig = hashlib.sha256(db_user.password_hash.encode()).hexdigest()[:16] + if not _hmac.compare_digest(claims.get("pwd_sig", ""), expected_sig): logger.warning( API_AUTH_FAILED, reason="password_changed_since_token_issued", user_id=user_id, - path=str(connection.url.path), + path=path, ) return None - authenticated = AuthenticatedUser( - user_id=db_user.id, - username=db_user.username, - role=db_user.role, - auth_method=AuthMethod.JWT, - must_change_password=db_user.must_change_password, - ) logger.info( API_AUTH_SUCCESS, user_id=db_user.id, username=db_user.username, auth_method="jwt", - path=str(connection.url.path), + path=path, + ) + return AuthenticatedUser( + user_id=db_user.id, + username=db_user.username, + role=db_user.role, + auth_method=AuthMethod.JWT, + must_change_password=db_user.must_change_password, ) - return authenticated async def _try_api_key_auth( token: str, + auth_service: AuthService, app_state: AppState, - connection: ASGIConnection[Any, Any, Any, Any], + path: str, ) -> AuthenticatedUser | None: """Attempt API key authentication. + Requires the JWT secret to be configured (used as the HMAC + key for hashing). Returns ``None`` gracefully if the secret + is missing. + Returns: - Authenticated user on success, or ``None`` if the key hash - is not found, the key is revoked or expired, or the owning - user no longer exists. + Authenticated user on success, or ``None`` on failure. """ - key_hash = AuthService.hash_api_key(token) - persistence = app_state.persistence - api_key = await persistence.api_keys.get_by_hash(key_hash) + try: + key_hash = auth_service.hash_api_key(token) + except SecretNotConfiguredError: + logger.exception( + API_AUTH_FAILED, + reason="api_key_hash_failed_secret_not_configured", + path=path, + ) + return None + + api_key = await app_state.persistence.api_keys.get_by_hash(key_hash) if api_key is None: - logger.debug( + logger.warning( API_AUTH_FAILED, reason="api_key_not_found", - path=str(connection.url.path), + path=path, ) return None + return await _resolve_api_key_user(api_key, app_state, path) + +async def _resolve_api_key_user( + api_key: ApiKey, + app_state: AppState, + path: str, +) -> AuthenticatedUser | None: + """Validate an API key (revocation, expiry) and resolve its owner.""" if api_key.revoked: logger.warning( API_AUTH_FAILED, reason="api_key_revoked", key_name=api_key.name, - path=str(connection.url.path), + path=path, ) return None if api_key.expires_at is not None and api_key.expires_at < datetime.now(UTC): @@ -216,37 +272,36 @@ async def _try_api_key_auth( API_AUTH_FAILED, reason="api_key_expired", key_name=api_key.name, - path=str(connection.url.path), + path=path, ) return None - db_user = await persistence.users.get(api_key.user_id) + db_user = await app_state.persistence.users.get(api_key.user_id) if db_user is None: logger.error( API_AUTH_FAILED, reason="api_key_orphaned", key_name=api_key.name, user_id=api_key.user_id, - path=str(connection.url.path), + path=path, ) return None - authenticated = AuthenticatedUser( - user_id=db_user.id, - username=db_user.username, - role=api_key.role, - auth_method=AuthMethod.API_KEY, - must_change_password=db_user.must_change_password, - ) logger.info( API_AUTH_SUCCESS, user_id=db_user.id, username=db_user.username, auth_method="api_key", key_name=api_key.name, - path=str(connection.url.path), + path=path, + ) + return AuthenticatedUser( + user_id=db_user.id, + username=db_user.username, + role=api_key.role, + auth_method=AuthMethod.API_KEY, + must_change_password=db_user.must_change_password, ) - return authenticated def create_auth_middleware_class( diff --git a/src/ai_company/api/auth/models.py b/src/ai_company/api/auth/models.py index 6c1c9f349a..11810924de 100644 --- a/src/ai_company/api/auth/models.py +++ b/src/ai_company/api/auth/models.py @@ -44,7 +44,7 @@ class ApiKey(BaseModel): Attributes: id: Unique key identifier (UUID). - key_hash: SHA-256 hex digest of the raw key. + key_hash: HMAC-SHA256 hex digest of the raw key. name: Human-readable label. role: Access control role. user_id: Owner user ID. @@ -56,7 +56,7 @@ class ApiKey(BaseModel): model_config = ConfigDict(frozen=True) id: NotBlankStr - key_hash: str = Field(repr=False) + key_hash: NotBlankStr = Field(repr=False) name: NotBlankStr role: HumanRole user_id: NotBlankStr diff --git a/src/ai_company/api/auth/service.py b/src/ai_company/api/auth/service.py index 9e57ceef1b..d788b478bd 100644 --- a/src/ai_company/api/auth/service.py +++ b/src/ai_company/api/auth/service.py @@ -2,6 +2,7 @@ import asyncio import hashlib +import hmac import secrets from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any @@ -18,6 +19,11 @@ logger = get_logger(__name__) + +class SecretNotConfiguredError(RuntimeError): + """Raised when the JWT secret is required but not configured.""" + + _hasher = argon2.PasswordHasher( time_cost=3, memory_cost=65536, @@ -37,6 +43,29 @@ class AuthService: def __init__(self, config: AuthConfig) -> None: self._config = config + def _require_secret(self, operation: str) -> str: + """Return the JWT secret or raise if unconfigured. + + Args: + operation: Name of the calling operation (for logging). + + Returns: + The JWT secret string. + + Raises: + SecretNotConfiguredError: If the JWT secret is empty. + """ + secret = self._config.jwt_secret + if not secret: + msg = "JWT secret not configured" + logger.error( + API_AUTH_FAILED, + reason="jwt_secret_missing", + operation=operation, + ) + raise SecretNotConfiguredError(msg) + return secret + def hash_password(self, password: str) -> str: """Hash a password with Argon2id. @@ -57,6 +86,12 @@ def verify_password(self, password: str, password_hash: str) -> bool: Returns: ``True`` if the password matches. + + Raises: + argon2.exceptions.VerificationError: On non-mismatch + verification failures (e.g. unsupported parameters). + argon2.exceptions.InvalidHashError: If the stored hash + is corrupted or malformed (data integrity issue). """ try: return _hasher.verify(password_hash, password) @@ -68,14 +103,14 @@ def verify_password(self, password: str, password_hash: str) -> bool: reason="hash_verification_error", exc_info=True, ) - return False + raise except argon2.exceptions.InvalidHashError: logger.error( API_AUTH_FAILED, reason="invalid_hash_data_corruption", exc_info=True, ) - return False + raise async def hash_password_async(self, password: str) -> str: """Hash a password with Argon2id in a thread executor. @@ -117,6 +152,14 @@ async def verify_password_async( def create_token(self, user: User) -> tuple[str, int]: """Create a JWT for the given user. + The token includes a ``pwd_sig`` claim — a 16-character + truncated SHA-256 of the stored password hash. This is + plain SHA-256, not HMAC — the password hash is already a + high-entropy Argon2id output, and the claim is protected + by the JWT signature. The auth middleware validates this + claim on every request so that tokens issued before a + password change are automatically rejected. + Args: user: Authenticated user. @@ -124,11 +167,9 @@ def create_token(self, user: User) -> tuple[str, int]: Tuple of (encoded JWT string, expiry seconds). Raises: - RuntimeError: If the JWT secret is empty. + SecretNotConfiguredError: If the JWT secret is empty. """ - if not self._config.jwt_secret: - msg = "JWT secret not configured" - raise RuntimeError(msg) + secret = self._require_secret("create_token") now = datetime.now(UTC) expiry_seconds = self._config.jwt_expiry_minutes * 60 pwd_sig = hashlib.sha256( @@ -145,7 +186,7 @@ def create_token(self, user: User) -> tuple[str, int]: } token = jwt.encode( payload, - self._config.jwt_secret, + secret, algorithm=self._config.jwt_algorithm, ) return token, expiry_seconds @@ -160,30 +201,39 @@ def decode_token(self, token: str) -> dict[str, Any]: Decoded claims dictionary. Raises: - RuntimeError: If the JWT secret is empty. + SecretNotConfiguredError: If the JWT secret is empty. jwt.InvalidTokenError: If the token is invalid or expired. """ - if not self._config.jwt_secret: - msg = "JWT secret not configured" - raise RuntimeError(msg) + secret = self._require_secret("decode_token") return jwt.decode( token, - self._config.jwt_secret, + secret, algorithms=[self._config.jwt_algorithm], options={"require": ["exp", "iat", "sub"]}, ) - @staticmethod - def hash_api_key(raw_key: str) -> str: - """Compute SHA-256 hex digest of a raw API key. + def hash_api_key(self, raw_key: str) -> str: + """Compute HMAC-SHA256 hex digest of a raw API key. + + Uses the server-side JWT secret as the HMAC key so that + an attacker with read access to stored hashes cannot + brute-force API keys offline. Args: raw_key: The plaintext API key. Returns: Lowercase hex digest. + + Raises: + SecretNotConfiguredError: If the JWT secret is empty. """ - return hashlib.sha256(raw_key.encode()).hexdigest() + secret = self._require_secret("hash_api_key") + return hmac.digest( + secret.encode(), + raw_key.encode(), + "sha256", + ).hex() @staticmethod def generate_api_key() -> str: diff --git a/src/ai_company/persistence/repositories.py b/src/ai_company/persistence/repositories.py index b03e7a8982..ab9d2a6c10 100644 --- a/src/ai_company/persistence/repositories.py +++ b/src/ai_company/persistence/repositories.py @@ -436,7 +436,7 @@ async def get_by_hash(self, key_hash: NotBlankStr) -> ApiKey | None: """Retrieve an API key by its hash. Args: - key_hash: SHA-256 hex digest. + key_hash: HMAC-SHA256 hex digest. Returns: The API key, or ``None`` if not found. diff --git a/src/ai_company/persistence/sqlite/user_repo.py b/src/ai_company/persistence/sqlite/user_repo.py index a04f71f3c4..fdd441caa2 100644 --- a/src/ai_company/persistence/sqlite/user_repo.py +++ b/src/ai_company/persistence/sqlite/user_repo.py @@ -397,10 +397,10 @@ async def get(self, key_id: NotBlankStr) -> ApiKey | None: return key async def get_by_hash(self, key_hash: NotBlankStr) -> ApiKey | None: - """Retrieve an API key by its SHA-256 hash. + """Retrieve an API key by its HMAC-SHA256 hash. Args: - key_hash: Hex-encoded SHA-256 digest of the raw key. + key_hash: Hex-encoded HMAC-SHA256 digest of the raw key. Returns: The matching ``ApiKey``, or ``None`` if not found. diff --git a/tests/unit/api/auth/test_middleware.py b/tests/unit/api/auth/test_middleware.py index 5a7c1ffc16..2886ab29b2 100644 --- a/tests/unit/api/auth/test_middleware.py +++ b/tests/unit/api/auth/test_middleware.py @@ -1,6 +1,6 @@ """Tests for ApiAuthMiddleware.""" -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest from litestar import Litestar, get @@ -11,10 +11,9 @@ from ai_company.api.auth.models import ApiKey, User from ai_company.api.auth.service import AuthService from ai_company.api.guards import HumanRole +from tests.unit.api.conftest import _TEST_JWT_SECRET as _SECRET from tests.unit.api.conftest import FakePersistenceBackend -_SECRET = "test-secret-that-is-at-least-32-characters-long" - def _make_auth_service() -> AuthService: return AuthService(AuthConfig(jwt_secret=_SECRET)) @@ -123,6 +122,52 @@ async def test_invalid_jwt_returns_401(self) -> None: ) assert resp.status_code == 401 + async def test_jwt_after_password_change_returns_401(self) -> None: + """Token issued before password change is rejected via pwd_sig.""" + svc = _make_auth_service() + user = _make_user(svc) + persistence = FakePersistenceBackend() + await persistence.connect() + await persistence.users.save(user) + + token, _ = svc.create_token(user) + + # Change password — new hash means different pwd_sig + updated_user = User( + id=user.id, + username=user.username, + password_hash=svc.hash_password("new-password-12chars"), + role=user.role, + must_change_password=False, + created_at=user.created_at, + updated_at=user.updated_at, + ) + await persistence.users.save(updated_user) + + app = _build_app(auth_service=svc, persistence=persistence) + with TestClient(app) as client: + resp = client.get( + "/protected", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 401 + + async def test_jwt_auth_with_unconfigured_secret_returns_401( + self, + ) -> None: + """JWT auth degrades to 401 when secret is unconfigured.""" + empty_svc = AuthService(AuthConfig()) + persistence = FakePersistenceBackend() + await persistence.connect() + app = _build_app(auth_service=empty_svc, persistence=persistence) + + with TestClient(app) as client: + resp = client.get( + "/protected", + headers={"Authorization": "Bearer some.jwt.token"}, + ) + assert resp.status_code == 401 + async def test_jwt_for_deleted_user_returns_401(self) -> None: svc = _make_auth_service() user = _make_user(svc) @@ -150,7 +195,7 @@ async def test_valid_api_key_authenticates(self) -> None: await persistence.users.save(user) raw_key = AuthService.generate_api_key() - key_hash = AuthService.hash_api_key(raw_key) + key_hash = svc.hash_api_key(raw_key) now = datetime.now(UTC) api_key = ApiKey( id="key-001", @@ -179,7 +224,7 @@ async def test_revoked_api_key_returns_401(self) -> None: await persistence.users.save(user) raw_key = AuthService.generate_api_key() - key_hash = AuthService.hash_api_key(raw_key) + key_hash = svc.hash_api_key(raw_key) now = datetime.now(UTC) api_key = ApiKey( id="key-002", @@ -202,8 +247,6 @@ async def test_revoked_api_key_returns_401(self) -> None: assert resp.status_code == 401 async def test_expired_api_key_returns_401(self) -> None: - from datetime import timedelta - svc = _make_auth_service() user = _make_user(svc) persistence = FakePersistenceBackend() @@ -211,7 +254,7 @@ async def test_expired_api_key_returns_401(self) -> None: await persistence.users.save(user) raw_key = AuthService.generate_api_key() - key_hash = AuthService.hash_api_key(raw_key) + key_hash = svc.hash_api_key(raw_key) now = datetime.now(UTC) api_key = ApiKey( id="key-003", @@ -245,7 +288,7 @@ async def test_api_key_with_deleted_owner_returns_401(self) -> None: await persistence.users.save(user) raw_key = AuthService.generate_api_key() - key_hash = AuthService.hash_api_key(raw_key) + key_hash = svc.hash_api_key(raw_key) now = datetime.now(UTC) api_key = ApiKey( id="key-orphan", @@ -267,6 +310,36 @@ async def test_api_key_with_deleted_owner_returns_401(self) -> None: ) assert resp.status_code == 401 + async def test_unknown_api_key_returns_401(self) -> None: + svc = _make_auth_service() + persistence = FakePersistenceBackend() + await persistence.connect() + app = _build_app(auth_service=svc, persistence=persistence) + + # Send a token without dots (API key path) that is not registered + with TestClient(app) as client: + resp = client.get( + "/protected", + headers={"Authorization": "Bearer unknownkey123456"}, + ) + assert resp.status_code == 401 + + async def test_api_key_auth_with_unconfigured_secret_returns_401( + self, + ) -> None: + empty_svc = AuthService(AuthConfig()) + persistence = FakePersistenceBackend() + await persistence.connect() + app = _build_app(auth_service=empty_svc, persistence=persistence) + + # hash_api_key raises SecretNotConfiguredError; middleware handles it + with TestClient(app) as client: + resp = client.get( + "/protected", + headers={"Authorization": "Bearer sometokenwithnodots"}, + ) + assert resp.status_code == 401 + @pytest.mark.unit class TestExtractBearerToken: diff --git a/tests/unit/api/auth/test_service.py b/tests/unit/api/auth/test_service.py index 3f2dda24e8..3e660ad051 100644 --- a/tests/unit/api/auth/test_service.py +++ b/tests/unit/api/auth/test_service.py @@ -2,14 +2,14 @@ from datetime import UTC, datetime, timedelta +import jwt import pytest from ai_company.api.auth.config import AuthConfig from ai_company.api.auth.models import User -from ai_company.api.auth.service import AuthService +from ai_company.api.auth.service import AuthService, SecretNotConfiguredError from ai_company.api.guards import HumanRole - -_SECRET = "test-secret-that-is-at-least-32-characters-long" +from tests.unit.api.conftest import _TEST_JWT_SECRET as _SECRET def _make_service() -> AuthService: @@ -59,13 +59,19 @@ def test_different_hashes_for_same_password(self) -> None: # Different salts produce different hashes assert h1 != h2 - def test_verify_password_with_corrupted_hash(self) -> None: + def test_verify_password_with_corrupted_hash_raises(self) -> None: + import argon2.exceptions + svc = _make_service() - assert not svc.verify_password("my-password", "not-a-valid-argon2-hash") + with pytest.raises(argon2.exceptions.InvalidHashError): + svc.verify_password("my-password", "not-a-valid-argon2-hash") + + def test_verify_password_with_empty_hash_raises(self) -> None: + import argon2.exceptions - def test_verify_password_with_empty_hash(self) -> None: svc = _make_service() - assert not svc.verify_password("my-password", "") + with pytest.raises(argon2.exceptions.InvalidHashError): + svc.verify_password("my-password", "") @pytest.mark.unit @@ -83,8 +89,6 @@ def test_create_and_decode(self) -> None: assert claims["role"] == "ceo" def test_expired_token_raises(self) -> None: - import jwt - config = AuthConfig(jwt_secret=_SECRET, jwt_expiry_minutes=1) svc = AuthService(config) user = _make_user() @@ -104,8 +108,6 @@ def test_expired_token_raises(self) -> None: svc.decode_token(expired_token) def test_invalid_signature_raises(self) -> None: - import jwt - svc = _make_service() user = _make_user() token, _ = svc.create_token(user) @@ -125,8 +127,6 @@ def test_must_change_password_in_claims(self) -> None: assert claims["must_change_password"] is True def test_decode_token_missing_sub_claim(self) -> None: - import jwt as pyjwt - svc = _make_service() payload = { "username": "admin", @@ -134,34 +134,58 @@ def test_decode_token_missing_sub_claim(self) -> None: "iat": datetime.now(UTC), "exp": datetime.now(UTC) + timedelta(hours=1), } - token = pyjwt.encode(payload, _SECRET, algorithm="HS256") - with pytest.raises(pyjwt.MissingRequiredClaimError): + token = jwt.encode(payload, _SECRET, algorithm="HS256") + with pytest.raises(jwt.MissingRequiredClaimError): svc.decode_token(token) def test_create_token_empty_secret_raises(self) -> None: svc = AuthService(AuthConfig()) user = _make_user() - with pytest.raises(RuntimeError, match="JWT secret not configured"): + with pytest.raises(SecretNotConfiguredError, match="JWT secret not configured"): svc.create_token(user) def test_decode_token_empty_secret_raises(self) -> None: svc = AuthService(AuthConfig()) - with pytest.raises(RuntimeError, match="JWT secret not configured"): + with pytest.raises(SecretNotConfiguredError, match="JWT secret not configured"): svc.decode_token("any.token.here") @pytest.mark.unit class TestApiKeyHashing: def test_hash_deterministic(self) -> None: - h1 = AuthService.hash_api_key("my-key") - h2 = AuthService.hash_api_key("my-key") + svc = _make_service() + h1 = svc.hash_api_key("my-key") + h2 = svc.hash_api_key("my-key") assert h1 == h2 def test_different_keys_different_hashes(self) -> None: - h1 = AuthService.hash_api_key("key-one") - h2 = AuthService.hash_api_key("key-two") + svc = _make_service() + h1 = svc.hash_api_key("key-one") + h2 = svc.hash_api_key("key-two") assert h1 != h2 + def test_hash_requires_secret(self) -> None: + svc = AuthService(AuthConfig()) + with pytest.raises(SecretNotConfiguredError, match="JWT secret not configured"): + svc.hash_api_key("some-key") + + def test_different_secrets_produce_different_hashes(self) -> None: + svc_a = AuthService( + AuthConfig(jwt_secret="secret-a-that-is-at-least-32-characters!") + ) + svc_b = AuthService( + AuthConfig(jwt_secret="secret-b-that-is-at-least-32-characters!") + ) + h_a = svc_a.hash_api_key("same-key") + h_b = svc_b.hash_api_key("same-key") + assert h_a != h_b + + def test_hash_output_is_64_char_hex(self) -> None: + svc = _make_service() + result = svc.hash_api_key("test-key") + assert len(result) == 64 + assert all(c in "0123456789abcdef" for c in result) + def test_generate_key_unique(self) -> None: k1 = AuthService.generate_api_key() k2 = AuthService.generate_api_key()