feat: harden chat state by tenant
This commit is contained in:
@@ -61,7 +61,7 @@
|
|||||||
| Future B1–B6 | 💤 vision | `CASAN_PLAN_FUTURE_PHASES.md` — approval workflow nâng cao · state machine · model benchmark · governed memory · auto-remediation · platform KPI. |
|
| Future B1–B6 | 💤 vision | `CASAN_PLAN_FUTURE_PHASES.md` — approval workflow nâng cao · state machine · model benchmark · governed memory · auto-remediation · platform KPI. |
|
||||||
| **17 Loop Engineering** | � T1–T6 done+test (offline) | **Agentic Loop Governance** — đủ 5 primitive + orchestrator (97/0 WSL, nối CI). T1 **Governor** (`loop-governor.py`; deny-by-default, no/corrupt policy→strict/HALT, on_exceed halt/escalate) 15/0; T2 **Convergence** (`loop-convergence.py`; repeat/thrash→OSCILLATING, flat→STALLED, fail-closed) 15/0; T3 **Verify Contract** (`loop-gate.py`; H4→DENY, unmet→FAIL, correction bounded→ESCALATE, no self-declared DONE) 20/0; T4 **Trace/Replay** (`loop-trace.py`; append-only hash-linked, edited→BREAK, tampered artifact→replay DRIFT) 16/0; T5 **Meta-loop** (`loop-metaloop.py`; propose≠apply, SoD, loosen>org_ceiling refused, apply qua governed CP store→đổi thật ceiling + rollback) 15/0; T6 **Orchestrator** (`loop-run.sh`; gate→governor→convergence→trace/turn, secure-by-default opt-out, nén giữa vòng) 16/0. State qua `CASAN_LOOP_STATE_ROOT` (repo `.specify/state` sạch). **Còn (infra):** T4 KMS-anchor head (A7 Vault), T6 widget Command Center (17.22, C5), live H3-judge. Chi tiết: `CASAN_PLAN_17_LOOP_ENGINEERING.md`. |
|
| **17 Loop Engineering** | � T1–T6 done+test (offline) | **Agentic Loop Governance** — đủ 5 primitive + orchestrator (97/0 WSL, nối CI). T1 **Governor** (`loop-governor.py`; deny-by-default, no/corrupt policy→strict/HALT, on_exceed halt/escalate) 15/0; T2 **Convergence** (`loop-convergence.py`; repeat/thrash→OSCILLATING, flat→STALLED, fail-closed) 15/0; T3 **Verify Contract** (`loop-gate.py`; H4→DENY, unmet→FAIL, correction bounded→ESCALATE, no self-declared DONE) 20/0; T4 **Trace/Replay** (`loop-trace.py`; append-only hash-linked, edited→BREAK, tampered artifact→replay DRIFT) 16/0; T5 **Meta-loop** (`loop-metaloop.py`; propose≠apply, SoD, loosen>org_ceiling refused, apply qua governed CP store→đổi thật ceiling + rollback) 15/0; T6 **Orchestrator** (`loop-run.sh`; gate→governor→convergence→trace/turn, secure-by-default opt-out, nén giữa vòng) 16/0. State qua `CASAN_LOOP_STATE_ROOT` (repo `.specify/state` sạch). **Còn (infra):** T4 KMS-anchor head (A7 Vault), T6 widget Command Center (17.22, C5), live H3-judge. Chi tiết: `CASAN_PLAN_17_LOOP_ENGINEERING.md`. |
|
||||||
| **16 Security audit remediation** | � P0/P1/P2 phần lớn done+test | **Remediation đã thực thi:** 28 SEC suite (151/0 WSL, nối `ci-harness-gate.sh`). Done: SEC-01..10, 12, 13, **14** (model-digest bỏ env-override ở prod/strict), 15, 16..21, **22** (trusted-time JWT `exp` ARCH-06 + tag proposal nguồn-không-tin ARCH-08), **26** (stored/second-order injection scan), 27..30, **23 Phase 1–5 offline** (multi-tenant: tenant-store+guard · per-tenant CP/audit/telemetry · RBAC data-boundary · tenant kill-switch/quota · ký registry · crypt at-rest per-tenant), **24 offline** (image digest-pin + ký workflow), **25 offline** (artifact attestation tested==deployed); **SEC-11 gộp vào SEC-17** (`CASAN_PROFILE=prod` enforce-by-default). **Còn 📋 planned (hạ tầng/process):** SEC-22 ARCH-10 (attestation ngoài) · SEC-23 23.11 (crypt qua Vault Transit) · **SEC-24 còn** (live CVE/OSV + scan image thật — offline image-pin/ký-workflow đã done) · **SEC-25 còn** (signed-commit enrollment + SLSA chain — offline artifact-attestation đã done). Chi tiết: `CASAN_PLAN_16` §0a/§2d. |
|
| **16 Security audit remediation** | � P0/P1/P2 phần lớn done+test | **Remediation đã thực thi:** 28 SEC suite (151/0 WSL, nối `ci-harness-gate.sh`). Done: SEC-01..10, 12, 13, **14** (model-digest bỏ env-override ở prod/strict), 15, 16..21, **22** (trusted-time JWT `exp` ARCH-06 + tag proposal nguồn-không-tin ARCH-08), **26** (stored/second-order injection scan), 27..30, **23 Phase 1–5 offline** (multi-tenant: tenant-store+guard · per-tenant CP/audit/telemetry · RBAC data-boundary · tenant kill-switch/quota · ký registry · crypt at-rest per-tenant), **24 offline** (image digest-pin + ký workflow), **25 offline** (artifact attestation tested==deployed); **SEC-11 gộp vào SEC-17** (`CASAN_PROFILE=prod` enforce-by-default). **Còn 📋 planned (hạ tầng/process):** SEC-22 ARCH-10 (attestation ngoài) · SEC-23 23.11 (crypt qua Vault Transit) · **SEC-24 còn** (live CVE/OSV + scan image thật — offline image-pin/ký-workflow đã done) · **SEC-25 còn** (signed-commit enrollment + SLSA chain — offline artifact-attestation đã done). Chi tiết: `CASAN_PLAN_16` §0a/§2d. |
|
||||||
| **18 Chat Console** | 🟡 **MVP-0 + MVP-1 + MVP-2 done+test; MVP-3 multi-tenant planned** · target arch còn planned | **Governed Chat Console** (cắt lát MVP chống lan man). **MVP-0 Ask CASAN read-only DONE**: `prompt-mode-router.py`, `chat-readonly.py`, `chat-session.schema.json`, H4 input/output scan, H5 chat audit hash-chain, H6 token telemetry, answer kèm evidence sources. **MVP-1 Operator DONE**: registered actions through `action-gate`, no free-command, action artifacts with provenance. **MVP-2 Track 4 DONE**: `agent-registry.yaml`, `chat-agent-resolver.py`, `chat:select_agent` RBAC, delegation hold, tool allowlist BLOCK, Control Panel agent picker, CODEGEN draft-only through `artifact-scan` + Plan-17 loop certification. **MVP-2 OPERATOR Track 5/6 DONE**: `chat-turn.py` certifies UNCERTIFIED draft through Plan-17 `loop-run.sh` + trace verify/replay before side-effect release. **Track 8.1/8.2 DONE**: `chat-replay.py` verifies chat chain, evidence artifact hash, and OPERATOR/CODEGEN loop replay; Control Panel `GET /api/v1/chat/replay`. **Track 8.3 DONE**: Command Center `chat_loop` widget reads chat audit/replay evidence, loop ticker, budget gauge, and click-through evidence drawer. **Track 8.4 DONE**: `REQUIRES_APPROVAL` chat turns become pending `chat.escalate` approvals, SoD/reason enforced, strict/fake JWT denied before mutation. Test: chat suites **51/0** (`phase-chat-prompt-router` 9/0, `phase-chat-readonly` 5/0, `phase-chat-session-audit` 3/0, `phase-chat-operator` 8/0, `phase-chat-agent-select` 8/0, `phase-chat-pipeline` 4/0, `phase-chat-stream-hold` 2/0, `phase-chat-replay` 4/0, `phase-chat-approval` 4/0, `phase-chat-codegen` 4/0), Control Panel **28/0** + build xanh. Next: MVP-3 multi-tenant. |
|
| **18 Chat Console** | ✅ **MVP-0 + MVP-1 + MVP-2 + MVP-3 done+test** | **Governed Chat Console** (cắt lát MVP chống lan man). **MVP-0 Ask CASAN read-only DONE**: `prompt-mode-router.py`, `chat-readonly.py`, `chat-session.schema.json`, H4 input/output scan, H5 chat audit hash-chain, H6 token telemetry, answer kèm evidence sources. **MVP-1 Operator DONE**: registered actions through `action-gate`, no free-command, action artifacts with provenance. **MVP-2 Track 4 DONE**: `agent-registry.yaml`, `chat-agent-resolver.py`, `chat:select_agent` RBAC, delegation hold, tool allowlist BLOCK, Control Panel agent picker, CODEGEN draft-only through `artifact-scan` + Plan-17 loop certification. **MVP-2 OPERATOR Track 5/6 DONE**: `chat-turn.py` certifies UNCERTIFIED draft through Plan-17 `loop-run.sh` + trace verify/replay before side-effect release. **Track 8.1/8.2 DONE**: `chat-replay.py` verifies chat chain, evidence artifact hash, and OPERATOR/CODEGEN loop replay; Control Panel `GET /api/v1/chat/replay`. **Track 8.3 DONE**: Command Center `chat_loop` widget reads chat audit/replay evidence, loop ticker, budget gauge, and click-through evidence drawer. **Track 8.4 DONE**: `REQUIRES_APPROVAL` chat turns become pending `chat.escalate` approvals, SoD/reason enforced, strict/fake JWT denied before mutation. **Track 9 DONE**: non-default tenant chat state is partitioned by `tenant-store.sh`, explicit cross-tenant replay paths are denied, encrypted audit snapshots are written via `tenant-crypt.sh`, tenant kill-switch/quota are isolated. Test: chat suites **59/0** (`phase-chat-prompt-router` 9/0, `phase-chat-readonly` 5/0, `phase-chat-session-audit` 3/0, `phase-chat-operator` 8/0, `phase-chat-agent-select` 8/0, `phase-chat-pipeline` 4/0, `phase-chat-stream-hold` 2/0, `phase-chat-replay` 4/0, `phase-chat-approval` 4/0, `phase-chat-codegen` 4/0, `phase-chat-tenant` 8/0), Control Panel **29/0** + build xanh. |
|
||||||
---
|
---
|
||||||
|
|
||||||
## Trần điểm & điều kiện lên "Strong (81+)"
|
## Trần điểm & điều kiện lên "Strong (81+)"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
| 15 | `CASAN_PLAN_15_RESPONSIBLE_AI_DATA_GOV.md` | Responsible AI & Data Governance (FPT §14.3–14.4) | 📋 |
|
| 15 | `CASAN_PLAN_15_RESPONSIBLE_AI_DATA_GOV.md` | Responsible AI & Data Governance (FPT §14.3–14.4) | 📋 |
|
||||||
| 16 | `CASAN_PLAN_16_SECURITY_AUDIT_REMEDIATION.md` | Security audit core harness + kế hoạch vá (tamper-evidence/injection/fail-open) | � P0/P1/P2 done |
|
| 16 | `CASAN_PLAN_16_SECURITY_AUDIT_REMEDIATION.md` | Security audit core harness + kế hoạch vá (tamper-evidence/injection/fail-open) | � P0/P1/P2 done |
|
||||||
| 17 | `CASAN_PLAN_17_LOOP_ENGINEERING.md` | Loop Engineering / Agentic Loop Governance (budget governor, convergence, verify-contract, loop trace/replay, meta-loop) | 📋 |
|
| 17 | `CASAN_PLAN_17_LOOP_ENGINEERING.md` | Loop Engineering / Agentic Loop Governance (budget governor, convergence, verify-contract, loop trace/replay, meta-loop) | 📋 |
|
||||||
| 18 | `CASAN_PLAN_18_CHAT_CONSOLE.md` | Governed Chat Console (target arch; **MVP-0 Ask CASAN + MVP-1 Operator + MVP-2 Chat-as-Loop/Agent/Replay done** → multi-tenant) | 🟡 MVP-0/1/2 done |
|
| 18 | `CASAN_PLAN_18_CHAT_CONSOLE.md` | Governed Chat Console (MVP-0 Ask CASAN + MVP-1 Operator + MVP-2 Chat-as-Loop/Agent/Replay + MVP-3 multi-tenant) | ✅ done+test |
|
||||||
| Future | `CASAN_PLAN_FUTURE_PHASES.md` | Approval workflow, state machine, benchmark, memory, remediation, KPI | 💤 vision |
|
| Future | `CASAN_PLAN_FUTURE_PHASES.md` | Approval workflow, state machine, benchmark, memory, remediation, KPI | 💤 vision |
|
||||||
|
|
||||||
> **Không có plan số 11:** số 11 được bỏ trống có chủ ý — nhánh eval/traceability đã
|
> **Không có plan số 11:** số 11 được bỏ trống có chủ ý — nhánh eval/traceability đã
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ package "DONE - Plan 13 Control Panel" #E8F5E9 {
|
|||||||
component "Loop/Chat widget\nbudget + replay drawer + chat ticker\nextends Command Center contract" as loop_widgets_done #D5F5E3
|
component "Loop/Chat widget\nbudget + replay drawer + chat ticker\nextends Command Center contract" as loop_widgets_done #D5F5E3
|
||||||
component "Approvals escalation\nturn ESCALATED -> inbox\nJWT + SoD + reason" as approvals_done #D5F5E3
|
component "Approvals escalation\nturn ESCALATED -> inbox\nJWT + SoD + reason" as approvals_done #D5F5E3
|
||||||
component "CODEGEN-as-loop\nH4 guarded codegen\nloop-certified artifacts" as codegen_done #D5F5E3
|
component "CODEGEN-as-loop\nH4 guarded codegen\nloop-certified artifacts" as codegen_done #D5F5E3
|
||||||
|
component "Chat multi-tenant hardening\nper-tenant state + quotas + crypto" as chat_mt_done #D5F5E3
|
||||||
}
|
}
|
||||||
|
|
||||||
package "NEXT - offline platform follow-ups" #FFF8E1 {
|
package "NEXT - offline platform follow-ups" #FFF8E1 {
|
||||||
component "RAI / Data Gov view\nretention + model-card + PII report" as rai #F9E79F
|
component "RAI / Data Gov view\nretention + model-card + PII report" as rai #F9E79F
|
||||||
component "Chat multi-tenant hardening\nper-tenant state + quotas + crypto" as chat_mt #F9E79F
|
|
||||||
}
|
}
|
||||||
|
|
||||||
package "EXTERNAL / MANAGED PROD" #FDEDEC {
|
package "EXTERNAL / MANAGED PROD" #FDEDEC {
|
||||||
@@ -46,10 +46,10 @@ command_done --> gov_core : provenance + audit state
|
|||||||
loop_widgets_done --> command_done : specialized widget
|
loop_widgets_done --> command_done : specialized widget
|
||||||
approvals_done --> gov_core : proposals + SoD
|
approvals_done --> gov_core : proposals + SoD
|
||||||
codegen_done --> gov_core : guarded loop-run
|
codegen_done --> gov_core : guarded loop-run
|
||||||
|
chat_mt_done --> gov_core : tenant boundaries
|
||||||
|
|
||||||
lead --> rai : next if staying offline
|
lead --> rai : next if staying offline
|
||||||
rai --> gov_core : read RAI + policy artifacts
|
rai --> gov_core : read RAI + policy artifacts
|
||||||
chat_mt --> gov_core : tenant boundaries
|
|
||||||
|
|
||||||
managed_oidc ..> local_prod : replace mock IdP/cert
|
managed_oidc ..> local_prod : replace mock IdP/cert
|
||||||
alerts ..> gov_core : route real incidents
|
alerts ..> gov_core : route real incidents
|
||||||
@@ -57,7 +57,7 @@ billing ..> command_done : ground-truth FinOps
|
|||||||
|
|
||||||
note right of rai
|
note right of rai
|
||||||
Recommended next:
|
Recommended next:
|
||||||
1. If no external infra: build RAI view or Plan-18 tenant hardening.
|
1. If no external infra: build RAI view or next backlog item.
|
||||||
2. If infra is available: promote local-prod OIDC to managed prod.
|
2. If infra is available: promote local-prod OIDC to managed prod.
|
||||||
3. Keep chat widgets on the existing Command envelope.
|
3. Keep chat widgets on the existing Command envelope.
|
||||||
4. Keep every metric evidence-backed with provenance.
|
4. Keep every metric evidence-backed with provenance.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# KẾ HOẠCH 18 — Governed Chat Console (Chat-as-Loop qua Control Plane)
|
# KẾ HOẠCH 18 — Governed Chat Console (Chat-as-Loop qua Control Plane)
|
||||||
|
|
||||||
> Status 2026-07-08: **🟡 MVP-0 + MVP-1 + MVP-2 done+test; MVP-3 multi-tenant còn planned.**
|
> Status 2026-07-08: **✅ MVP-0 + MVP-1 + MVP-2 + MVP-3 done+test.**
|
||||||
> Đã implement **Ask CASAN — Read-only Evidence Assistant** qua harness + Control
|
> Đã implement **Ask CASAN — Read-only Evidence Assistant** qua harness + Control
|
||||||
> Panel (`/api/v1/chat/ask`, `/chat` UI): Prompt Router deterministic
|
> Panel (`/api/v1/chat/ask`, `/chat` UI): Prompt Router deterministic
|
||||||
> `READ_ONLY/OPERATOR/BLOCK/NOT_SUPPORTED`, context whitelist, H4 scan in/out, H5
|
> `READ_ONLY/OPERATOR/BLOCK/NOT_SUPPORTED`, context whitelist, H4 scan in/out, H5
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
> loop certification. **Track 8.1/8.2/8.3/8.4** đã có replay/verify-chain
|
> loop certification. **Track 8.1/8.2/8.3/8.4** đã có replay/verify-chain
|
||||||
> foundation, Command Center Chat/Loop widget đọc chat audit/replay evidence thật,
|
> foundation, Command Center Chat/Loop widget đọc chat audit/replay evidence thật,
|
||||||
> và escalation vào approvals inbox.
|
> và escalation vào approvals inbox.
|
||||||
|
> **Track 9/MVP-3** đã partition chat state theo tenant, guard explicit paths,
|
||||||
|
> ghi encrypted audit snapshot, và scope kill-switch/quota theo tenant.
|
||||||
> Mục tiêu
|
> Mục tiêu
|
||||||
> tổng thể vẫn là thêm **cửa sổ chat** vào Control Plane (Plan-13) như một
|
> tổng thể vẫn là thêm **cửa sổ chat** vào Control Plane (Plan-13) như một
|
||||||
> **bề mặt tương tác của core harness** — mỗi lượt chat là **một loop-run được
|
> **bề mặt tương tác của core harness** — mỗi lượt chat là **một loop-run được
|
||||||
@@ -297,9 +299,9 @@ flowchart TD
|
|||||||
### Track 9 — Multi-tenant hardening `[MVP-3]`
|
### Track 9 — Multi-tenant hardening `[MVP-3]`
|
||||||
| Task | Việc | File | Verify (WSL) |
|
| Task | Việc | File | Verify (WSL) |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 18.9.1 | tenant-partition state + quyền FS (SEC-23); RBAC ở **cả tầng dữ liệu** | store per-tenant | tenant A không đọc/ghi state B |
|
| 18.9.1 | ✅ tenant-partition state + quyền FS (SEC-23); RBAC ở **cả tầng dữ liệu** | `tenant-store.sh` + chat audit/replay paths | tenant A không đọc/ghi state B |
|
||||||
| 18.9.2 | per-tenant encrypt at-rest (MT-02) | nối KMS | admin A không đọc audit B |
|
| 18.9.2 | ✅ per-tenant encrypt at-rest (MT-02 offline snapshot) | `tenant-crypt.sh` | admin/tenant B không decrypt audit A |
|
||||||
| 18.9.3 | quota / kill-switch scope theo tenant (MT-03) | nối kill-switch | 1 tenant burn budget không ảnh hưởng tenant khác |
|
| 18.9.3 | ✅ quota / kill-switch scope theo tenant (MT-03) | `kill-switch.sh` + `cost-spike-detect.sh` | 1 tenant burn budget không ảnh hưởng tenant khác |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -379,12 +381,19 @@ cập nhật tổng test ở `CASAN_HARDENING_STATUS.md`.
|
|||||||
- [x] Control Panel `/approvals` can pass an approval JWT for strict review; dev mode remains backward-compatible.
|
- [x] Control Panel `/approvals` can pass an approval JWT for strict review; dev mode remains backward-compatible.
|
||||||
- [x] Verify: `phase-chat-approval` 4/0, `phase-chat-agent-select` 8/0, `phase-chat-replay` 4/0, `console:test` 28/0, `console:build` xanh.
|
- [x] Verify: `phase-chat-approval` 4/0, `phase-chat-agent-select` 8/0, `phase-chat-replay` 4/0, `console:test` 28/0, `console:build` xanh.
|
||||||
|
|
||||||
|
**MVP-3 Track 9 (Multi-tenant hardening):**
|
||||||
|
- [x] Non-default tenant chat turns write audit/head/agent-binding/metrics/artifacts/loop state under `tenant-store.sh` partitions; default single-tenant path remains backward-compatible.
|
||||||
|
- [x] `chat-replay.py` and explicit chat audit overrides are guarded by tenant-store, so tenant A cannot point replay at tenant B's audit path.
|
||||||
|
- [x] Chat audit writes create per-tenant encrypted `.enc` snapshots via `tenant-crypt.sh`; tenant B cannot decrypt tenant A's snapshot.
|
||||||
|
- [x] Chat entry checks tenant-scoped kill-switch and tenant cost quota before work; tenant A halt/quota does not halt tenant B.
|
||||||
|
- [x] Verify: `phase-chat-tenant` 8/0, `console:test` 29/0, `console:build` xanh.
|
||||||
|
|
||||||
**Full (target architecture) — DoD tổng:**
|
**Full (target architecture) — DoD tổng:**
|
||||||
- [ ] Turn chạy **đúng như một loop-run Plan-17** (không định nghĩa vòng lặp riêng).
|
- [ ] Turn chạy **đúng như một loop-run Plan-17** (không định nghĩa vòng lặp riêng).
|
||||||
- [ ] **Không đường vòng:** test chứng minh bỏ bất kỳ gate nào (preflight/verify) → FAIL.
|
- [ ] **Không đường vòng:** test chứng minh bỏ bất kỳ gate nào (preflight/verify) → FAIL.
|
||||||
- [ ] Chọn agent/skill qua **RBAC + allowlist**; nâng delegation cần **approval JWT + SoD**.
|
- [ ] Chọn agent/skill qua **RBAC + allowlist**; nâng delegation cần **approval JWT + SoD**.
|
||||||
- [ ] Streaming: side-effect **luôn** sau gate; draft DENY → thu hồi, không tác dụng phụ.
|
- [ ] Streaming: side-effect **luôn** sau gate; draft DENY → thu hồi, không tác dụng phụ.
|
||||||
- [ ] Chat state **per-tenant**; test cross-tenant bị chặn ở tầng dữ liệu.
|
- [x] Chat state **per-tenant**; test cross-tenant bị chặn ở tầng dữ liệu.
|
||||||
- [ ] Turn **replay được** + `verify-chain` phát hiện tamper.
|
- [ ] Turn **replay được** + `verify-chain` phát hiện tamper.
|
||||||
- [ ] API chỉ **bọc** harness (không verdict riêng); UI đọc **artifact thật** + click-to-evidence.
|
- [ ] API chỉ **bọc** harness (không verdict riêng); UI đọc **artifact thật** + click-to-evidence.
|
||||||
- [ ] Không đụng OKR app; core harness giữ nguyên số test + thêm suite chat.
|
- [ ] Không đụng OKR app; core harness giữ nguyên số test + thêm suite chat.
|
||||||
@@ -392,11 +401,12 @@ cập nhật tổng test ở `CASAN_HARDENING_STATUS.md`.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 7. Ghi chú trung thực & Non-goals (không lan man)
|
## 7. Ghi chú trung thực & Non-goals (không lan man)
|
||||||
- **[MVP-0 + MVP-1 + MVP-2] — 🟡 done+test.**
|
- **[MVP-0 + MVP-1 + MVP-2 + MVP-3] — ✅ done+test.**
|
||||||
Ask CASAN read-only, Operator registered actions, agent/skill governance foundation,
|
Ask CASAN read-only, Operator registered actions, agent/skill governance foundation,
|
||||||
CODEGEN draft-as-loop, OPERATOR chat-as-loop/draft-hold, replay/verify-chain foundation,
|
CODEGEN draft-as-loop, OPERATOR chat-as-loop/draft-hold, replay/verify-chain foundation,
|
||||||
Command Center Chat/Loop widget, approvals escalation đã có harness CLI + Control Panel
|
Command Center Chat/Loop widget, approvals escalation đã có harness CLI + Control Panel
|
||||||
API/UI. Phase multi-tenant production vẫn chưa mở.
|
API/UI. Multi-tenant chat state, replay guard, encrypted audit snapshot, tenant kill-switch,
|
||||||
|
và tenant quota đã có test đối kháng.
|
||||||
- **MVP-first (chống lan man):** **MVP-0 (Ask CASAN read-only) KHÔNG phụ thuộc Plan-17/
|
- **MVP-first (chống lan man):** **MVP-0 (Ask CASAN read-only) KHÔNG phụ thuộc Plan-17/
|
||||||
14/SEC-23** — làm được ngay trên nền H4/H5/H6 hiện có. Chỉ **MVP-2 trở đi** (chat-as-
|
14/SEC-23** — làm được ngay trên nền H4/H5/H6 hiện có. Chỉ **MVP-2 trở đi** (chat-as-
|
||||||
loop + agent) mới cần Plan-17 (T1–T3) + Plan-14 RBAC; **MVP-3** mới cần SEC-23 tenant.
|
loop + agent) mới cần Plan-17 (T1–T3) + Plan-14 RBAC; **MVP-3** mới cần SEC-23 tenant.
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ approval JWT thật ([16 SEC-07](CASAN_PLAN_16_SECURITY_AUDIT_REMEDIATION.md)),
|
|||||||
| B10 | ✅ **done** — **MVP-1 Operator mode**: registered actions `run tests` / `build pack` / `verify pack`, no free-command, all through `action-gate`, action artifacts with provenance, Control Panel quick actions. `phase-chat-operator` 8/0; total chat suites 24/0; Control Panel 24/0 + build xanh. | [18 Track 3](CASAN_PLAN_18_CHAT_CONSOLE.md) | M | ✅ | done |
|
| B10 | ✅ **done** — **MVP-1 Operator mode**: registered actions `run tests` / `build pack` / `verify pack`, no free-command, all through `action-gate`, action artifacts with provenance, Control Panel quick actions. `phase-chat-operator` 8/0; total chat suites 24/0; Control Panel 24/0 + build xanh. | [18 Track 3](CASAN_PLAN_18_CHAT_CONSOLE.md) | M | ✅ | done |
|
||||||
| B11 | ✅ **MVP-2 done** — Track 4 Agent/Skill governance + CODEGEN draft-as-loop + OPERATOR Track 5/6 chat-as-loop/draft-hold + Track 8.1/8.2 replay foundation + Track 8.3 Command Center Chat/Loop widget + Track 8.4 approvals escalation. `chat-turn.py` certifies OPERATOR/CODEGEN drafts through Plan-17 `loop-run.sh`; `chat-replay.py` detects evidence artifact drift and chat-chain tamper; `/command` shows chat loop ticker/budget/evidence; delegation escalation creates pending `chat.escalate` approvals. Chat suites 51/0; Control Panel 28/0 + build xanh. | [18 Track 4/5/6/8](CASAN_PLAN_18_CHAT_CONSOLE.md) | L | ✅ | B6, C7(RBAC), B10 |
|
| B11 | ✅ **MVP-2 done** — Track 4 Agent/Skill governance + CODEGEN draft-as-loop + OPERATOR Track 5/6 chat-as-loop/draft-hold + Track 8.1/8.2 replay foundation + Track 8.3 Command Center Chat/Loop widget + Track 8.4 approvals escalation. `chat-turn.py` certifies OPERATOR/CODEGEN drafts through Plan-17 `loop-run.sh`; `chat-replay.py` detects evidence artifact drift and chat-chain tamper; `/command` shows chat loop ticker/budget/evidence; delegation escalation creates pending `chat.escalate` approvals. Chat suites 51/0; Control Panel 28/0 + build xanh. | [18 Track 4/5/6/8](CASAN_PLAN_18_CHAT_CONSOLE.md) | L | ✅ | B6, C7(RBAC), B10 |
|
||||||
| B12 | ✅ **done** — Widget Loop/Chat trên Command Center (`chat_loop` widget, loop ticker, budget gauge, replay evidence drawer) | [17 (17.22)](CASAN_PLAN_17_LOOP_ENGINEERING.md) · [18 Track 8](CASAN_PLAN_18_CHAT_CONSOLE.md) | M | ✅ | C5 ✅ baseline |
|
| B12 | ✅ **done** — Widget Loop/Chat trên Command Center (`chat_loop` widget, loop ticker, budget gauge, replay evidence drawer) | [17 (17.22)](CASAN_PLAN_17_LOOP_ENGINEERING.md) · [18 Track 8](CASAN_PLAN_18_CHAT_CONSOLE.md) | M | ✅ | C5 ✅ baseline |
|
||||||
| B13 | **MVP-3** multi-tenant chat hardening (Track 9) | [18 Track 9](CASAN_PLAN_18_CHAT_CONSOLE.md) | M | 🔗 | A3(SEC-23) |
|
| B13 | ✅ **done** — MVP-3 multi-tenant chat hardening (Track 9): tenant-partitioned chat state/replay guard, encrypted audit snapshot, tenant kill-switch/quota isolation. `phase-chat-tenant` 8/0; Control Panel tenant replay test included in 29/0. | [18 Track 9](CASAN_PLAN_18_CHAT_CONSOLE.md) | M | ✅ | A3(SEC-23) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ Governed Chat (Plan-18 MVP-0/1/2):
|
|||||||
with role visibility; selected agent/skill/delegation are bound by harness
|
with role visibility; selected agent/skill/delegation are bound by harness
|
||||||
`chat-agent-resolver.py`.
|
`chat-agent-resolver.py`.
|
||||||
- `GET /api/v1/chat/audit/verify` — verifies the chat audit hash chain.
|
- `GET /api/v1/chat/audit/verify` — verifies the chat audit hash chain.
|
||||||
- `GET /api/v1/chat/replay?chatId=<id>` — verifies chat-chain integrity,
|
- `GET /api/v1/chat/replay?chatId=<id>&tenant=<id>` — verifies chat-chain integrity,
|
||||||
evidence artifact hashes, and OPERATOR loop trace replay through harness
|
evidence artifact hashes, and OPERATOR loop trace replay through harness
|
||||||
`chat-replay.py`.
|
`chat-replay.py`; non-default tenants read only their tenant partition.
|
||||||
- `GET /api/v1/command` — includes the `chat_loop` Command Center widget backed
|
- `GET /api/v1/command` — includes the `chat_loop` Command Center widget backed
|
||||||
by chat audit/replay evidence, loop ticker rows, and token budget gauge.
|
by chat audit/replay evidence, loop ticker rows, and token budget gauge.
|
||||||
- Delegation escalation returns `ESCALATED` and creates a pending `chat.escalate`
|
- Delegation escalation returns `ESCALATED` and creates a pending `chat.escalate`
|
||||||
@@ -79,6 +79,9 @@ Governed Chat (Plan-18 MVP-0/1/2):
|
|||||||
- CODEGEN requests are draft-only: `codegen-draft` outputs a state artifact, runs
|
- CODEGEN requests are draft-only: `codegen-draft` outputs a state artifact, runs
|
||||||
`artifact-scan.sh` + `tool-output-scan.sh`, certifies through Plan-17 loop-run,
|
`artifact-scan.sh` + `tool-output-scan.sh`, certifies through Plan-17 loop-run,
|
||||||
and never writes to the source tree.
|
and never writes to the source tree.
|
||||||
|
- Non-default tenant chat state is resolved through `tenant-store.sh`; replay path
|
||||||
|
overrides are guarded, audit snapshots are encrypted with `tenant-crypt.sh`, and
|
||||||
|
tenant kill-switch/quota checks run before chat work.
|
||||||
- `/chat` UI shows actor/role scope, `mode/risk/decision` badges, certified answer,
|
- `/chat` UI shows actor/role scope, `mode/risk/decision` badges, certified answer,
|
||||||
evidence sources, registered operator actions, agent binding, loop certification,
|
evidence sources, registered operator actions, agent binding, loop certification,
|
||||||
codegen draft scan status, action-gate status, router details, and audit hash. Side-effect requests outside
|
codegen draft scan status, action-gate status, router details, and audit hash. Side-effect requests outside
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export class ChatController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('replay')
|
@Get('replay')
|
||||||
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string) {
|
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string, @Query('tenant') tenant?: string) {
|
||||||
return ok(this.svc.replay(chatId || '', turnId || ''));
|
return ok(this.svc.replay(chatId || '', turnId || '', tenant || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('actions')
|
@Get('actions')
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
|
|||||||
const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py');
|
const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py');
|
||||||
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
|
||||||
|
|
||||||
function runPython(script: string, args: string[]): CommandResult {
|
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}): CommandResult {
|
||||||
try {
|
try {
|
||||||
const stdout = execFileSync('python3', [script, ...args], {
|
const stdout = execFileSync('python3', [script, ...args], {
|
||||||
cwd: APP_ROOT,
|
cwd: APP_ROOT,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: process.env,
|
env: { ...process.env, ...extraEnv },
|
||||||
});
|
});
|
||||||
return { status: 0, stdout: stdout.trim(), stderr: '' };
|
return { status: 0, stdout: stdout.trim(), stderr: '' };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -94,11 +94,12 @@ export class ChatService {
|
|||||||
return { ok: res.status === 0, output: res.stdout || res.stderr };
|
return { ok: res.status === 0, output: res.stdout || res.stderr };
|
||||||
}
|
}
|
||||||
|
|
||||||
replay(chatId = '', turnId = '') {
|
replay(chatId = '', turnId = '', tenant = '') {
|
||||||
const args = ['replay'];
|
const args = ['replay'];
|
||||||
if (chatId) args.push('--chat-id', chatId);
|
if (chatId) args.push('--chat-id', chatId);
|
||||||
if (turnId) args.push('--turn-id', turnId);
|
if (turnId) args.push('--turn-id', turnId);
|
||||||
const res = runPython(REPLAY_CLI, args);
|
const env = tenant && tenant !== 'default' ? { CASAN_TENANT_ID: tenant } : {};
|
||||||
|
const res = runPython(REPLAY_CLI, args, env);
|
||||||
const parsed = parseJson<Record<string, any>>(res.stdout);
|
const parsed = parseJson<Record<string, any>>(res.stdout);
|
||||||
if (parsed) return { ok: res.status === 0, ...parsed };
|
if (parsed) return { ok: res.status === 0, ...parsed };
|
||||||
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_REPLAY_FAILED');
|
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_REPLAY_FAILED');
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ function withTempChatState(fn: () => void) {
|
|||||||
CASAN_CHAT_AUDIT_HEAD: process.env.CASAN_CHAT_AUDIT_HEAD,
|
CASAN_CHAT_AUDIT_HEAD: process.env.CASAN_CHAT_AUDIT_HEAD,
|
||||||
CASAN_CHAT_METRICS_LOG: process.env.CASAN_CHAT_METRICS_LOG,
|
CASAN_CHAT_METRICS_LOG: process.env.CASAN_CHAT_METRICS_LOG,
|
||||||
CASAN_APPROVAL_INBOX_FILE: process.env.CASAN_APPROVAL_INBOX_FILE,
|
CASAN_APPROVAL_INBOX_FILE: process.env.CASAN_APPROVAL_INBOX_FILE,
|
||||||
|
CASAN_TENANT_STATE_ROOT: process.env.CASAN_TENANT_STATE_ROOT,
|
||||||
};
|
};
|
||||||
const state = mkdtempSync(join(tmpdir(), 'cp-chat-'));
|
const state = mkdtempSync(join(tmpdir(), 'cp-chat-'));
|
||||||
process.env.CASAN_STATE_ROOT = state;
|
process.env.CASAN_STATE_ROOT = state;
|
||||||
|
process.env.CASAN_TENANT_STATE_ROOT = join(state, 'tenants');
|
||||||
process.env.CASAN_APPROVAL_INBOX_FILE = join(state, 'approval-inbox.json');
|
process.env.CASAN_APPROVAL_INBOX_FILE = join(state, 'approval-inbox.json');
|
||||||
delete process.env.CASAN_CHAT_AUDIT_LOG;
|
delete process.env.CASAN_CHAT_AUDIT_LOG;
|
||||||
delete process.env.CASAN_CHAT_AUDIT_HEAD;
|
delete process.env.CASAN_CHAT_AUDIT_HEAD;
|
||||||
@@ -135,3 +137,22 @@ test('chat ask escalates delegation approval into approval inbox', () => {
|
|||||||
assert.equal(inbox.proposals[0].payload.agent_binding.decision, 'REQUIRES_APPROVAL');
|
assert.equal(inbox.proposals[0].payload.agent_binding.decision, 'REQUIRES_APPROVAL');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('chat replay is partitioned by non-default tenant', () => {
|
||||||
|
withTempChatState(() => {
|
||||||
|
const svc = new ChatService();
|
||||||
|
const alpha = { ...viewer, actor: 'tenant-alpha', tenant: 'alpha' };
|
||||||
|
const res = svc.ask({ message: 'Summarize Plan 18 MVP-2 status', chatId: 'tenant-chat' }, alpha) as any;
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
assert.equal(res.audit_verify.ok, true);
|
||||||
|
|
||||||
|
const alphaReplay = svc.replay('tenant-chat', '', 'alpha') as any;
|
||||||
|
assert.equal(alphaReplay.ok, true);
|
||||||
|
assert.equal(alphaReplay.decision, 'MATCH');
|
||||||
|
assert.equal(alphaReplay.records, 1);
|
||||||
|
|
||||||
|
const betaReplay = svc.replay('tenant-chat', '', 'beta') as any;
|
||||||
|
assert.equal(betaReplay.ok, true);
|
||||||
|
assert.equal(betaReplay.records, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export const api = {
|
|||||||
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
|
askChat: (actor: SettingsActor, body: { message: string; chatId?: string; agentId?: string; skillId?: string; delegationLevel?: number }) =>
|
||||||
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
|
post<ChatAnswer>('chat/ask', body, actorHeaders(actor)),
|
||||||
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
verifyChatAudit: () => get<{ ok: boolean; output: string }>('chat/audit/verify'),
|
||||||
replayChat: (chatId = '', turnId = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}`),
|
replayChat: (chatId = '', turnId = '', tenant = '') => get<ChatReplay>(`chat/replay?chatId=${encodeURIComponent(chatId)}&turnId=${encodeURIComponent(turnId)}&tenant=${encodeURIComponent(tenant)}`),
|
||||||
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
chatActions: (actor: SettingsActor) => getWithHeaders<{ success: boolean; actions: ChatAction[] }>('chat/actions', actorHeaders(actor)),
|
||||||
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
|
chatAgents: (actor: SettingsActor) => getWithHeaders<{ success: boolean; agents: ChatAgent[] }>('chat/agents', actorHeaders(actor)),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import argparse
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ def project_root() -> str:
|
|||||||
|
|
||||||
ROOT = project_root()
|
ROOT = project_root()
|
||||||
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
|
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
|
||||||
|
TENANT_STORE = os.path.join(HARNESS_ROOT, "scripts", "bash", "tenant-store.sh")
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
@@ -38,9 +40,26 @@ def now_iso() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def inbox_path() -> str:
|
def inbox_path() -> str:
|
||||||
return os.environ.get("CASAN_APPROVAL_INBOX_FILE") or os.path.join(
|
if os.environ.get("CASAN_APPROVAL_INBOX_FILE"):
|
||||||
ROOT, ".specify", "level5", "approval-inbox.json"
|
return guarded_override(os.environ["CASAN_APPROVAL_INBOX_FILE"])
|
||||||
)
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
return tenant_path("approvals/approval-inbox.json")
|
||||||
|
return os.path.join(ROOT, ".specify", "level5", "approval-inbox.json")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_path(logical: str) -> str:
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def guarded_override(path: str) -> str:
|
||||||
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def policy_path() -> str:
|
def policy_path() -> str:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ ROOT = project_root()
|
|||||||
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
|
HARNESS_ROOT = os.path.join(ROOT, "packages", "casan-harness")
|
||||||
HARNESS_BIN = os.path.join(HARNESS_ROOT, "scripts", "bash")
|
HARNESS_BIN = os.path.join(HARNESS_ROOT, "scripts", "bash")
|
||||||
RBAC = os.path.join(HARNESS_BIN, "rbac-check.py")
|
RBAC = os.path.join(HARNESS_BIN, "rbac-check.py")
|
||||||
|
TENANT_STORE = os.path.join(HARNESS_BIN, "tenant-store.sh")
|
||||||
GENESIS_HASH = "0" * 64
|
GENESIS_HASH = "0" * 64
|
||||||
|
|
||||||
|
|
||||||
@@ -44,16 +45,33 @@ def state_root() -> str:
|
|||||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_path(logical: str, fallback: str) -> str:
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
return os.path.join(state_root(), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def guarded_override(path: str) -> str:
|
||||||
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def registry_path() -> str:
|
def registry_path() -> str:
|
||||||
return os.environ.get("CASAN_AGENT_REGISTRY_FILE") or os.path.join(HARNESS_ROOT, "config", "agent-registry.yaml")
|
return os.environ.get("CASAN_AGENT_REGISTRY_FILE") or os.path.join(HARNESS_ROOT, "config", "agent-registry.yaml")
|
||||||
|
|
||||||
|
|
||||||
def audit_path() -> str:
|
def audit_path() -> str:
|
||||||
return os.environ.get("CASAN_AGENT_BIND_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "agent-bindings.jsonl")
|
return guarded_override(os.environ["CASAN_AGENT_BIND_AUDIT_LOG"]) if os.environ.get("CASAN_AGENT_BIND_AUDIT_LOG") else tenant_path("chat/agent-bindings.jsonl", "logs/chat/agent-bindings.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def head_path() -> str:
|
def head_path() -> str:
|
||||||
return os.environ.get("CASAN_AGENT_BIND_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "agent-bindings-head.txt")
|
return guarded_override(os.environ["CASAN_AGENT_BIND_AUDIT_HEAD"]) if os.environ.get("CASAN_AGENT_BIND_AUDIT_HEAD") else tenant_path("chat/agent-bindings-head.txt", "logs/chat/agent-bindings-head.txt")
|
||||||
|
|
||||||
|
|
||||||
def load_registry():
|
def load_registry():
|
||||||
@@ -149,6 +167,8 @@ def run_rbac(args):
|
|||||||
|
|
||||||
|
|
||||||
def bind(args) -> int:
|
def bind(args) -> int:
|
||||||
|
if args.tenant and args.tenant != "default":
|
||||||
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||||
registry = load_registry()
|
registry = load_registry()
|
||||||
if registry.get("_error"):
|
if registry.get("_error"):
|
||||||
out = decision_payload(args, "DENIED", registry["_error"])
|
out = decision_payload(args, "DENIED", registry["_error"])
|
||||||
|
|||||||
@@ -35,30 +35,49 @@ HARNESS_BIN = os.path.join(HARNESS_ROOT, "scripts", "bash")
|
|||||||
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
|
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
|
||||||
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
|
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
|
||||||
ACTION_GATE = os.path.join(HARNESS_BIN, "action-gate.sh")
|
ACTION_GATE = os.path.join(HARNESS_BIN, "action-gate.sh")
|
||||||
|
TENANT_STORE = os.path.join(HARNESS_BIN, "tenant-store.sh")
|
||||||
|
TENANT_CRYPT = os.path.join(HARNESS_BIN, "tenant-crypt.sh")
|
||||||
|
|
||||||
|
|
||||||
def state_root() -> str:
|
def state_root() -> str:
|
||||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_path(logical: str, fallback: str) -> str:
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
return os.path.join(state_root(), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def guarded_override(path: str) -> str:
|
||||||
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def config_path() -> str:
|
def config_path() -> str:
|
||||||
return os.environ.get("CASAN_OPERATOR_ACTIONS_FILE") or os.path.join(HARNESS_ROOT, "config", "operator-actions.yaml")
|
return os.environ.get("CASAN_OPERATOR_ACTIONS_FILE") or os.path.join(HARNESS_ROOT, "config", "operator-actions.yaml")
|
||||||
|
|
||||||
|
|
||||||
def audit_path() -> str:
|
def audit_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_LOG"]) if os.environ.get("CASAN_CHAT_AUDIT_LOG") else tenant_path("chat/chat-turns.jsonl", "logs/chat/chat-turns.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def head_path() -> str:
|
def head_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_HEAD"]) if os.environ.get("CASAN_CHAT_AUDIT_HEAD") else tenant_path("chat/chat-head.txt", "logs/chat/chat-head.txt")
|
||||||
|
|
||||||
|
|
||||||
def metrics_path() -> str:
|
def metrics_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_METRICS_LOG") or os.path.join(state_root(), "logs", "cost", "metrics.jsonl")
|
return guarded_override(os.environ["CASAN_CHAT_METRICS_LOG"]) if os.environ.get("CASAN_CHAT_METRICS_LOG") else tenant_path("telemetry/cost/metrics.jsonl", "logs/cost/metrics.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def artifact_dir() -> str:
|
def artifact_dir() -> str:
|
||||||
return os.path.join(state_root(), "logs", "chat", "operator-artifacts")
|
return tenant_path("chat/operator-artifacts", "logs/chat/operator-artifacts")
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
@@ -181,9 +200,18 @@ def record_turn(base):
|
|||||||
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
||||||
with open(head_path(), "w", encoding="utf-8") as fh:
|
with open(head_path(), "w", encoding="utf-8") as fh:
|
||||||
fh.write(record_hash + "\n")
|
fh.write(record_hash + "\n")
|
||||||
|
encrypt_chat_audit_snapshot(path)
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_chat_audit_snapshot(path: str):
|
||||||
|
if not os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
return
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return
|
||||||
|
subprocess.run(["bash", TENANT_CRYPT, "encrypt", path, path + ".enc"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
def record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int, action_id: str):
|
def record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int, action_id: str):
|
||||||
input_tokens = len(message.split())
|
input_tokens = len(message.split())
|
||||||
output_tokens = len(answer.split())
|
output_tokens = len(answer.split())
|
||||||
@@ -287,6 +315,8 @@ def list_actions(args) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def run(args) -> int:
|
def run(args) -> int:
|
||||||
|
if args.tenant and args.tenant != "default":
|
||||||
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||||
started = datetime.now(timezone.utc)
|
started = datetime.now(timezone.utc)
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
chat_id = args.chat_id or "chat-default"
|
chat_id = args.chat_id or "chat-default"
|
||||||
|
|||||||
@@ -39,22 +39,41 @@ ROOT = project_root()
|
|||||||
HARNESS_BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
HARNESS_BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
||||||
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
|
ROUTER = os.path.join(HARNESS_BIN, "prompt-mode-router.py")
|
||||||
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
|
SECURITY = os.path.join(HARNESS_BIN, "security-check.sh")
|
||||||
|
TENANT_STORE = os.path.join(HARNESS_BIN, "tenant-store.sh")
|
||||||
|
TENANT_CRYPT = os.path.join(HARNESS_BIN, "tenant-crypt.sh")
|
||||||
|
|
||||||
|
|
||||||
def state_root() -> str:
|
def state_root() -> str:
|
||||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_path(logical: str, fallback: str) -> str:
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
return os.path.join(state_root(), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def guarded_override(path: str) -> str:
|
||||||
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def audit_path() -> str:
|
def audit_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_LOG"]) if os.environ.get("CASAN_CHAT_AUDIT_LOG") else tenant_path("chat/chat-turns.jsonl", "logs/chat/chat-turns.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def head_path() -> str:
|
def head_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_HEAD"]) if os.environ.get("CASAN_CHAT_AUDIT_HEAD") else tenant_path("chat/chat-head.txt", "logs/chat/chat-head.txt")
|
||||||
|
|
||||||
|
|
||||||
def metrics_path() -> str:
|
def metrics_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_METRICS_LOG") or os.path.join(state_root(), "logs", "cost", "metrics.jsonl")
|
return guarded_override(os.environ["CASAN_CHAT_METRICS_LOG"]) if os.environ.get("CASAN_CHAT_METRICS_LOG") else tenant_path("telemetry/cost/metrics.jsonl", "logs/cost/metrics.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
@@ -223,9 +242,18 @@ def record_turn(base):
|
|||||||
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
||||||
with open(head_path(), "w", encoding="utf-8") as fh:
|
with open(head_path(), "w", encoding="utf-8") as fh:
|
||||||
fh.write(record_hash + "\n")
|
fh.write(record_hash + "\n")
|
||||||
|
encrypt_chat_audit_snapshot(path)
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_chat_audit_snapshot(path: str):
|
||||||
|
if not os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
return
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return
|
||||||
|
subprocess.run(["bash", TENANT_CRYPT, "encrypt", path, path + ".enc"], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
def record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int):
|
def record_metrics(trace_id: str, message: str, answer: str, status: str, latency_ms: int):
|
||||||
input_tokens = len(message.split())
|
input_tokens = len(message.split())
|
||||||
output_tokens = len(answer.split())
|
output_tokens = len(answer.split())
|
||||||
@@ -253,6 +281,8 @@ def record_metrics(trace_id: str, message: str, answer: str, status: str, latenc
|
|||||||
|
|
||||||
|
|
||||||
def ask(args):
|
def ask(args):
|
||||||
|
if args.tenant and args.tenant != "default":
|
||||||
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||||
started = datetime.now(timezone.utc)
|
started = datetime.now(timezone.utc)
|
||||||
trace_id = str(uuid.uuid4())
|
trace_id = str(uuid.uuid4())
|
||||||
message = args.message
|
message = args.message
|
||||||
|
|||||||
@@ -33,14 +33,32 @@ def project_root() -> str:
|
|||||||
ROOT = project_root()
|
ROOT = project_root()
|
||||||
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
BIN = os.path.join(ROOT, "packages", "casan-harness", "scripts", "bash")
|
||||||
LOOP_TRACE = os.path.join(BIN, "loop-trace.py")
|
LOOP_TRACE = os.path.join(BIN, "loop-trace.py")
|
||||||
|
TENANT_STORE = os.path.join(BIN, "tenant-store.sh")
|
||||||
|
|
||||||
|
|
||||||
def state_root() -> str:
|
def state_root() -> str:
|
||||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_path(logical: str, fallback: str) -> str:
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
return os.path.join(state_root(), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def guarded_override(path: str) -> str:
|
||||||
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def audit_path() -> str:
|
def audit_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_LOG"]) if os.environ.get("CASAN_CHAT_AUDIT_LOG") else tenant_path("chat/chat-turns.jsonl", "logs/chat/chat-turns.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def sha_text(text: str) -> str:
|
def sha_text(text: str) -> str:
|
||||||
@@ -87,12 +105,12 @@ def resolve_path(path: str) -> str:
|
|||||||
def loop_state_root(loop_run):
|
def loop_state_root(loop_run):
|
||||||
explicit = os.environ.get("CASAN_LOOP_STATE_ROOT")
|
explicit = os.environ.get("CASAN_LOOP_STATE_ROOT")
|
||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
return guarded_override(explicit)
|
||||||
artifact = loop_run.get("artifact") or ""
|
artifact = loop_run.get("artifact") or ""
|
||||||
marker = os.sep + "logs" + os.sep + "chat" + os.sep + "loop-runs" + os.sep
|
marker = os.sep + "logs" + os.sep + "chat" + os.sep + "loop-runs" + os.sep
|
||||||
if marker in artifact:
|
if marker in artifact:
|
||||||
return artifact.split(marker, 1)[0] + os.sep + "logs" + os.sep + "chat" + os.sep + "loop-state"
|
return artifact.split(marker, 1)[0] + os.sep + "logs" + os.sep + "chat" + os.sep + "loop-state"
|
||||||
return os.path.join(state_root(), "logs", "chat", "loop-state")
|
return tenant_path("chat/loop-state", "logs/chat/loop-state")
|
||||||
|
|
||||||
|
|
||||||
def run_loop_trace(loop_run, cmd: str):
|
def run_loop_trace(loop_run, cmd: str):
|
||||||
|
|||||||
@@ -38,22 +38,43 @@ PREFLIGHT = os.path.join(BIN, "harness-preflight.sh")
|
|||||||
CONTEXT_SCAN = os.path.join(BIN, "context-assemble-scan.sh")
|
CONTEXT_SCAN = os.path.join(BIN, "context-assemble-scan.sh")
|
||||||
TOOL_OUTPUT_SCAN = os.path.join(BIN, "tool-output-scan.sh")
|
TOOL_OUTPUT_SCAN = os.path.join(BIN, "tool-output-scan.sh")
|
||||||
ARTIFACT_SCAN = os.path.join(BIN, "artifact-scan.sh")
|
ARTIFACT_SCAN = os.path.join(BIN, "artifact-scan.sh")
|
||||||
|
TENANT_STORE = os.path.join(BIN, "tenant-store.sh")
|
||||||
|
TENANT_CRYPT = os.path.join(BIN, "tenant-crypt.sh")
|
||||||
|
KILL_SWITCH = os.path.join(BIN, "kill-switch.sh")
|
||||||
|
COST_SPIKE = os.path.join(BIN, "cost-spike-detect.sh")
|
||||||
|
|
||||||
|
|
||||||
def state_root() -> str:
|
def state_root() -> str:
|
||||||
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
return os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_path(logical: str, fallback: str) -> str:
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "resolve", logical], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return r.stdout.strip()
|
||||||
|
return os.path.join(state_root(), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def guarded_override(path: str) -> str:
|
||||||
|
if path and os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
r = subprocess.run(["bash", TENANT_STORE, "guard", path], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit((r.stderr or r.stdout or "TENANT_DENIED").strip())
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
def audit_path() -> str:
|
def audit_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_LOG") or os.path.join(state_root(), "logs", "chat", "chat-turns.jsonl")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_LOG"]) if os.environ.get("CASAN_CHAT_AUDIT_LOG") else tenant_path("chat/chat-turns.jsonl", "logs/chat/chat-turns.jsonl")
|
||||||
|
|
||||||
|
|
||||||
def head_path() -> str:
|
def head_path() -> str:
|
||||||
return os.environ.get("CASAN_CHAT_AUDIT_HEAD") or os.path.join(state_root(), "logs", "chat", "chat-head.txt")
|
return guarded_override(os.environ["CASAN_CHAT_AUDIT_HEAD"]) if os.environ.get("CASAN_CHAT_AUDIT_HEAD") else tenant_path("chat/chat-head.txt", "logs/chat/chat-head.txt")
|
||||||
|
|
||||||
|
|
||||||
def sha(text: str) -> str:
|
def sha(text: str) -> str:
|
||||||
@@ -147,11 +168,11 @@ def write_text(path: str, text: str):
|
|||||||
|
|
||||||
|
|
||||||
def loop_dir(run_id: str) -> str:
|
def loop_dir(run_id: str) -> str:
|
||||||
return os.path.join(state_root(), "logs", "chat", "loop-runs", run_id)
|
return tenant_path(f"chat/loop-runs/{run_id}", f"logs/chat/loop-runs/{run_id}")
|
||||||
|
|
||||||
|
|
||||||
def codegen_dir(run_id: str) -> str:
|
def codegen_dir(run_id: str) -> str:
|
||||||
return os.path.join(state_root(), "logs", "chat", "codegen-artifacts", run_id)
|
return tenant_path(f"chat/codegen-artifacts/{run_id}", f"logs/chat/codegen-artifacts/{run_id}")
|
||||||
|
|
||||||
|
|
||||||
def load_chat_head() -> str:
|
def load_chat_head() -> str:
|
||||||
@@ -177,9 +198,65 @@ def append_chat_turn(base):
|
|||||||
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
os.makedirs(os.path.dirname(head_path()), exist_ok=True)
|
||||||
with open(head_path(), "w", encoding="utf-8") as fh:
|
with open(head_path(), "w", encoding="utf-8") as fh:
|
||||||
fh.write(record_hash + "\n")
|
fh.write(record_hash + "\n")
|
||||||
|
encrypt_chat_audit_snapshot(path)
|
||||||
return rec
|
return rec
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_chat_audit_snapshot(path: str):
|
||||||
|
if not os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
return
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return
|
||||||
|
enc = path + ".enc"
|
||||||
|
subprocess.run(["bash", TENANT_CRYPT, "encrypt", path, enc], cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def tenant_runtime_guard(args):
|
||||||
|
if args.tenant and args.tenant != "default":
|
||||||
|
os.environ["CASAN_TENANT_ID"] = args.tenant
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
ks = subprocess.run(["bash", KILL_SWITCH, "check", "tenant", os.environ["CASAN_TENANT_ID"]], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if ks.returncode != 0:
|
||||||
|
print(json.dumps({
|
||||||
|
"success": False,
|
||||||
|
"mode": "BLOCK",
|
||||||
|
"risk": "high",
|
||||||
|
"decision": "HALTED",
|
||||||
|
"answer": f"Tenant kill-switch active for {os.environ['CASAN_TENANT_ID']}.",
|
||||||
|
"sources": [],
|
||||||
|
"certified": False,
|
||||||
|
"audit": {},
|
||||||
|
"router": {"reason": "tenant_kill_switch_active", "matched_rules": ["tenant_kill_switch"]},
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return 3
|
||||||
|
if os.environ.get("CASAN_COST_CUMULATIVE_BUDGET_TOKENS"):
|
||||||
|
quota = subprocess.run(["bash", COST_SPIKE], cwd=ROOT, capture_output=True, text=True)
|
||||||
|
if quota.returncode == 2:
|
||||||
|
print(json.dumps({
|
||||||
|
"success": False,
|
||||||
|
"mode": "BLOCK",
|
||||||
|
"risk": "high",
|
||||||
|
"decision": "HALTED",
|
||||||
|
"answer": f"Tenant quota exceeded for {os.environ['CASAN_TENANT_ID']}.",
|
||||||
|
"sources": [],
|
||||||
|
"certified": False,
|
||||||
|
"audit": {},
|
||||||
|
"router": {"reason": "tenant_quota_exceeded", "matched_rules": ["tenant_quota"]},
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
return 3
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def loop_env_for(args):
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"CASAN_LOOP_STATE_ROOT": os.environ.get("CASAN_LOOP_STATE_ROOT") or tenant_path("chat/loop-state", "logs/chat/loop-state"),
|
||||||
|
}
|
||||||
|
if os.environ.get("CASAN_TENANT_ID"):
|
||||||
|
env["CASAN_TENANT_ID"] = os.environ["CASAN_TENANT_ID"]
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def run_preflight_and_context(run_id: str, draft_path: str):
|
def run_preflight_and_context(run_id: str, draft_path: str):
|
||||||
preflight_out = os.path.join(loop_dir(run_id), "preflight.json")
|
preflight_out = os.path.join(loop_dir(run_id), "preflight.json")
|
||||||
r = subprocess.run(["bash", PREFLIGHT, draft_path, preflight_out, "--model", "local:chat-turn"], cwd=ROOT, capture_output=True, text=True)
|
r = subprocess.run(["bash", PREFLIGHT, draft_path, preflight_out, "--model", "local:chat-turn"], cwd=ROOT, capture_output=True, text=True)
|
||||||
@@ -235,11 +312,7 @@ def certify_operator_draft(args, router, binding):
|
|||||||
"success_criteria": criteria_path,
|
"success_criteria": criteria_path,
|
||||||
}, 2
|
}, 2
|
||||||
|
|
||||||
loop_env = {
|
loop_env = loop_env_for(args)
|
||||||
**os.environ,
|
|
||||||
"CASAN_LOOP_STATE_ROOT": os.environ.get("CASAN_LOOP_STATE_ROOT") or os.path.join(state_root(), "logs", "chat", "loop-state"),
|
|
||||||
"CASAN_TENANT_ID": args.tenant,
|
|
||||||
}
|
|
||||||
dlevel = f"L{binding.get('delegation_level', 0)}"
|
dlevel = f"L{binding.get('delegation_level', 0)}"
|
||||||
r = subprocess.run([
|
r = subprocess.run([
|
||||||
"bash", LOOP_RUN,
|
"bash", LOOP_RUN,
|
||||||
@@ -318,11 +391,7 @@ def certify_codegen_draft(args, router, binding):
|
|||||||
"artifact_scan": {"ok": False, "output": scan_out},
|
"artifact_scan": {"ok": False, "output": scan_out},
|
||||||
}, 2
|
}, 2
|
||||||
|
|
||||||
loop_env = {
|
loop_env = loop_env_for(args)
|
||||||
**os.environ,
|
|
||||||
"CASAN_LOOP_STATE_ROOT": os.environ.get("CASAN_LOOP_STATE_ROOT") or os.path.join(state_root(), "logs", "chat", "loop-state"),
|
|
||||||
"CASAN_TENANT_ID": args.tenant,
|
|
||||||
}
|
|
||||||
dlevel = f"L{binding.get('delegation_level', 0)}"
|
dlevel = f"L{binding.get('delegation_level', 0)}"
|
||||||
r = subprocess.run([
|
r = subprocess.run([
|
||||||
"bash", LOOP_RUN,
|
"bash", LOOP_RUN,
|
||||||
@@ -530,6 +599,9 @@ def submit_escalation(args, router, binding):
|
|||||||
|
|
||||||
|
|
||||||
def ask(args) -> int:
|
def ask(args) -> int:
|
||||||
|
guard_rc = tenant_runtime_guard(args)
|
||||||
|
if guard_rc != 0:
|
||||||
|
return guard_rc
|
||||||
router = classify(args.message)
|
router = classify(args.message)
|
||||||
bind_rc, binding = bind_agent(args, router)
|
bind_rc, binding = bind_agent(args, router)
|
||||||
if bind_rc != 0:
|
if bind_rc != 0:
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ run "phase-chat-stream-hold" bash "$TESTS/phase-chat-stream-hold-tests.sh"
|
|||||||
run "phase-chat-replay" bash "$TESTS/phase-chat-replay-tests.sh"
|
run "phase-chat-replay" bash "$TESTS/phase-chat-replay-tests.sh"
|
||||||
run "phase-chat-approval" bash "$TESTS/phase-chat-approval-tests.sh"
|
run "phase-chat-approval" bash "$TESTS/phase-chat-approval-tests.sh"
|
||||||
run "phase-chat-codegen" bash "$TESTS/phase-chat-codegen-tests.sh"
|
run "phase-chat-codegen" bash "$TESTS/phase-chat-codegen-tests.sh"
|
||||||
|
run "phase-chat-tenant" bash "$TESTS/phase-chat-tenant-tests.sh"
|
||||||
|
|
||||||
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently
|
# ARCH-02: coverage cannot silently drop; ARCH-01: harness/policy cannot silently
|
||||||
# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
|
# drift. Both SKIP cleanly when no manifest is provisioned (non-strict dev/CI).
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ python3 "$TURN" ask \
|
|||||||
--actor alice \
|
--actor alice \
|
||||||
--role viewer \
|
--role viewer \
|
||||||
--chat-id approval-chat \
|
--chat-id approval-chat \
|
||||||
--tenant tenant-a \
|
--tenant default \
|
||||||
--delegation-level 1 > "$WORK/escalated.json"
|
--delegation-level 1 > "$WORK/escalated.json"
|
||||||
RC=$?
|
RC=$?
|
||||||
set -e 2>/dev/null || true
|
set -e 2>/dev/null || true
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
# Plan-18 MVP-3 Track 9: chat state is tenant-partitioned, guarded,
|
||||||
|
# encrypted-at-rest as an offline snapshot, and scoped for tenant resources.
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/../scripts/bash/casan-paths.sh"
|
||||||
|
S="$CASAN_HARNESS_ROOT/scripts/bash"
|
||||||
|
TURN="$S/chat-turn.py"
|
||||||
|
REPLAY="$S/chat-replay.py"
|
||||||
|
TS="$S/tenant-store.sh"
|
||||||
|
TC="$S/tenant-crypt.sh"
|
||||||
|
KS="$S/kill-switch.sh"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
export CASAN_STATE_ROOT="$WORK/state"
|
||||||
|
export CASAN_TENANT_STATE_ROOT="$WORK/tenants"
|
||||||
|
export CASAN_KILLSWITCH_DIR="$WORK/ks"
|
||||||
|
|
||||||
|
PASS=0; FAIL=0
|
||||||
|
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
||||||
|
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }
|
||||||
|
rc_of() { set +e; "$@" >/dev/null 2>&1; echo $?; set -e 2>/dev/null || true; }
|
||||||
|
|
||||||
|
echo "===== Plan-18 MVP-3 chat tenant hardening ====="
|
||||||
|
|
||||||
|
CASAN_TENANT_ID=alpha python3 "$TURN" ask --message "Summarize Plan 18 status" --actor alice --role viewer --tenant alpha --chat-id tenant-chat > "$WORK/alpha.json"
|
||||||
|
CASAN_TENANT_ID=beta python3 "$TURN" ask --message "Summarize Plan 18 status" --actor bob --role viewer --tenant beta --chat-id tenant-chat > "$WORK/beta.json"
|
||||||
|
|
||||||
|
pA="$(CASAN_TENANT_ID=alpha bash "$TS" resolve chat/chat-turns.jsonl 2>/dev/null)"
|
||||||
|
pB="$(CASAN_TENANT_ID=beta bash "$TS" resolve chat/chat-turns.jsonl 2>/dev/null)"
|
||||||
|
{ [[ -f "$pA" && -f "$pB" && "$pA" != "$pB" ]]; } \
|
||||||
|
&& pass "18.9.1 tenant A/B chat audits are separate files" || fail "chat audit partitioning failed"
|
||||||
|
|
||||||
|
CASAN_TENANT_ID=alpha python3 "$REPLAY" verify-chain > "$WORK/alpha-chain.json"
|
||||||
|
CASAN_TENANT_ID=beta python3 "$REPLAY" verify-chain > "$WORK/beta-chain.json"
|
||||||
|
python3 - "$WORK/alpha-chain.json" "$WORK/beta-chain.json" <<'PY' \
|
||||||
|
&& pass "18.9.1 each tenant verifies only its own chat chain" || fail "tenant chat chain verify mismatch"
|
||||||
|
import json, sys
|
||||||
|
a = json.load(open(sys.argv[1]))
|
||||||
|
b = json.load(open(sys.argv[2]))
|
||||||
|
assert a["decision"] == "OK" and a["records"] == 1
|
||||||
|
assert b["decision"] == "OK" and b["records"] == 1
|
||||||
|
PY
|
||||||
|
|
||||||
|
[[ "$(rc_of env CASAN_TENANT_ID=alpha CASAN_CHAT_AUDIT_LOG="$pB" python3 "$REPLAY" verify-chain)" -ne 0 ]] \
|
||||||
|
&& pass "18.9.1 explicit cross-tenant audit path is denied" || fail "cross-tenant audit override was allowed"
|
||||||
|
|
||||||
|
[[ -f "$pA.enc" ]] \
|
||||||
|
&& pass "18.9.2 encrypted chat audit snapshot is written" || fail "encrypted audit snapshot missing"
|
||||||
|
CASAN_TENANT_ID=alpha bash "$TC" decrypt "$pA.enc" "$WORK/alpha.dec" >/dev/null 2>&1
|
||||||
|
grep -q "tenant-chat" "$WORK/alpha.dec" \
|
||||||
|
&& pass "18.9.2 tenant A decrypts its own chat audit snapshot" || fail "tenant A could not decrypt own audit"
|
||||||
|
CASAN_TENANT_ID=beta bash "$TC" decrypt "$pA.enc" "$WORK/beta.dec" >/dev/null 2>&1 || true
|
||||||
|
grep -q "tenant-chat" "$WORK/beta.dec" \
|
||||||
|
&& fail "18.9.2 tenant B read tenant A audit plaintext" \
|
||||||
|
|| pass "18.9.2 tenant B cannot decrypt tenant A audit snapshot"
|
||||||
|
|
||||||
|
bash "$KS" engage tenant alpha "chat incident" >/dev/null 2>&1
|
||||||
|
set +e
|
||||||
|
CASAN_TENANT_ID=alpha python3 "$TURN" ask --message "Summarize Plan 18 status" --actor alice --role viewer --tenant alpha --chat-id killed > "$WORK/killed.json"
|
||||||
|
RC_A=$?
|
||||||
|
CASAN_TENANT_ID=beta python3 "$TURN" ask --message "Summarize Plan 18 status" --actor bob --role viewer --tenant beta --chat-id alive > "$WORK/alive.json"
|
||||||
|
RC_B=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
python3 - "$WORK/killed.json" "$WORK/alive.json" "$RC_A" "$RC_B" <<'PY' \
|
||||||
|
&& pass "18.9.3 tenant kill-switch halts only its own tenant" || fail "tenant kill-switch isolation failed"
|
||||||
|
import json, sys
|
||||||
|
k = json.load(open(sys.argv[1]))
|
||||||
|
a = json.load(open(sys.argv[2]))
|
||||||
|
assert int(sys.argv[3]) == 3
|
||||||
|
assert k["decision"] == "HALTED"
|
||||||
|
assert int(sys.argv[4]) == 0
|
||||||
|
assert a["success"] is True
|
||||||
|
PY
|
||||||
|
|
||||||
|
gLOG="$(CASAN_TENANT_ID=gamma bash "$TS" resolve telemetry/provider-usage.jsonl 2>/dev/null)"
|
||||||
|
dLOG="$(CASAN_TENANT_ID=delta bash "$TS" resolve telemetry/provider-usage.jsonl 2>/dev/null)"
|
||||||
|
printf '{"step":"chat","total_tokens":9000}\n' > "$gLOG"
|
||||||
|
printf '{"step":"chat","total_tokens":10}\n' > "$dLOG"
|
||||||
|
set +e
|
||||||
|
CASAN_TENANT_ID=gamma CASAN_COST_CUMULATIVE_BUDGET_TOKENS=1000 python3 "$TURN" ask --message "Summarize Plan 18 status" --actor g --role viewer --tenant gamma --chat-id quota > "$WORK/quota.json"
|
||||||
|
RC_G=$?
|
||||||
|
CASAN_TENANT_ID=delta CASAN_COST_CUMULATIVE_BUDGET_TOKENS=1000 python3 "$TURN" ask --message "Summarize Plan 18 status" --actor d --role viewer --tenant delta --chat-id quota > "$WORK/quota-ok.json"
|
||||||
|
RC_D=$?
|
||||||
|
set -e 2>/dev/null || true
|
||||||
|
python3 - "$WORK/quota.json" "$WORK/quota-ok.json" "$RC_G" "$RC_D" <<'PY' \
|
||||||
|
&& pass "18.9.3 tenant quota halts only over-budget tenant" || fail "tenant quota isolation failed"
|
||||||
|
import json, sys
|
||||||
|
q = json.load(open(sys.argv[1]))
|
||||||
|
ok = json.load(open(sys.argv[2]))
|
||||||
|
assert int(sys.argv[3]) == 3
|
||||||
|
assert q["decision"] == "HALTED"
|
||||||
|
assert int(sys.argv[4]) == 0
|
||||||
|
assert ok["success"] is True
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===== CHAT TENANT SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||||
|
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||||
@@ -19,7 +19,7 @@ Optional layer for teams that want UI / dashboard / visibility. Packages: `casan
|
|||||||
| RBAC + approval inbox | ✅ local-prod done | Settings + kill-switch API RBAC enforcement, approval inbox/delegation/oversight, SoD, governed setting proposal apply, and local OIDC claim→role mapping smoke are done. Enterprise IdP rollout remains production follow-up. |
|
| RBAC + approval inbox | ✅ local-prod done | Settings + kill-switch API RBAC enforcement, approval inbox/delegation/oversight, SoD, governed setting proposal apply, and local OIDC claim→role mapping smoke are done. Enterprise IdP rollout remains production follow-up. |
|
||||||
| Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` |
|
| Evidence Pack Viewer | 📋 planned | reads `docs/output/casan/evidence-packs/` |
|
||||||
| Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results |
|
| Attack Battery Viewer | 📋 planned | reads red-team corpus + H4 recall results |
|
||||||
| Read-only Ask CASAN + Operator/CODEGEN + agent selection + loop-hold + replay/widget/approvals | 🟡 MVP-0/1/2 done+test; MVP-3 planned | Plan-18: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions`, `GET /api/v1/chat/agents`, `GET /api/v1/chat/replay`, Command Center `chat_loop` widget, chat escalation into `/approvals`, `/chat` + `/command`, backed by harness `chat-turn.py`, `chat-agent-resolver.py`, `chat-replay.py`, `approval-inbox.py`, `artifact-scan.sh`, and Plan-17 `loop-run.sh` |
|
| Read-only Ask CASAN + Operator/CODEGEN + agent selection + loop-hold + replay/widget/approvals + tenant hardening | ✅ MVP-0/1/2/3 done+test | Plan-18: `POST /api/v1/chat/ask`, `GET /api/v1/chat/actions`, `GET /api/v1/chat/agents`, `GET /api/v1/chat/replay`, Command Center `chat_loop` widget, chat escalation into `/approvals`, tenant-scoped replay, `/chat` + `/command`, backed by harness `chat-turn.py`, `chat-agent-resolver.py`, `chat-replay.py`, `approval-inbox.py`, `artifact-scan.sh`, `tenant-store.sh`, `tenant-crypt.sh`, and Plan-17 `loop-run.sh` |
|
||||||
| Gitea webhook integration | 📋 planned | trigger gate / publish evidence on push |
|
| Gitea webhook integration | 📋 planned | trigger gate / publish evidence on push |
|
||||||
|
|
||||||
## Build (preview)
|
## Build (preview)
|
||||||
@@ -29,6 +29,6 @@ scripts/package-release.sh platform # → dist/casan-platform-preview-vX.Y.Z
|
|||||||
The bundle includes a `PREVIEW-INCOMPLETE.txt` marker. Do not treat it as a finished product.
|
The bundle includes a `PREVIEW-INCOMPLETE.txt` marker. Do not treat it as a finished product.
|
||||||
|
|
||||||
## To implement later
|
## To implement later
|
||||||
Start from Plan-15 RAI view or Plan-18 Track 9 multi-tenant hardening. Keep new numbers
|
Start from Plan-15 RAI view or the next backlog item. Keep new numbers
|
||||||
evidence-backed with provenance; any write/governed action must continue to route through
|
evidence-backed with provenance; any write/governed action must continue to route through
|
||||||
harness RBAC, approval, and audit primitives.
|
harness RBAC, approval, and audit primitives.
|
||||||
|
|||||||
Reference in New Issue
Block a user