P6: security soak, axe a11y sign-off, signed air-gapped bundle tooling
- Concurrency soak (exit criterion): 12 concurrent gated runs, every decision attempted twice concurrently, explicit resumes racing the durable sweeper — exactly one execution per approval, grants consumed at most once, every decision audited, zero stuck runs, zero unaudited executions. (Testkit pool raised to 20 connections; the 5-connection pool starved the storm.) - axe a11y sweep (exit criterion): serious+critical violations fail CI on login, shell, chat, computer home, settings app, all global pages, and the wizard. Two real violations found and fixed: aria-label on a plain div (wizard progress -> role=group) and a button directly inside a <dl> (settings -> plain bordered list). - tools/bundler (exit criterion): keygen / assemble / verify CLI — copies artifacts, writes manifest.json + sha256 checksums.txt + a detached ed25519 signature; verification is fully offline (keyless signing is internet-dependent and disqualified). Tests: round trip, tampered artifact caught by hash, tampered checksum list caught by signature, wrong key refused, missing artifact reported. 147 Rust + 63 frontend tests + 27 Playwright journeys (incl. 4 a11y). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
a8efada690
commit
ccf96053e6
Generated
+81
@@ -538,6 +538,33 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@@ -639,6 +666,31 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@@ -714,6 +766,12 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figment"
|
name = "figment"
|
||||||
version = "0.10.19"
|
version = "0.10.19"
|
||||||
@@ -2210,6 +2268,15 @@ version = "2.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc_version"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||||
|
dependencies = [
|
||||||
|
"semver",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -3130,6 +3197,20 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "teamclaw-bundler"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ed25519-dalek",
|
||||||
|
"hex",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"thiserror",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "teamclaw-server"
|
name = "teamclaw-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ members = [
|
|||||||
"crates/tc-api",
|
"crates/tc-api",
|
||||||
"crates/bins/teamclaw-server",
|
"crates/bins/teamclaw-server",
|
||||||
"crates/bins/teamclaw-broker",
|
"crates/bins/teamclaw-broker",
|
||||||
|
"tools/bundler",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! P6 security soak (spec §17 exit): under concurrent load with racing
|
||||||
|
//! decisions and duplicated resume attempts, ZERO unaudited gated
|
||||||
|
//! executions — every outbox row maps to exactly one approved decision,
|
||||||
|
//! every grant is consumed at most once, and nothing executes twice.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tc_domain::{
|
||||||
|
AccessPolicy, Agent, AgentId, AgentStatus, Role, RunState, User, UserId, Workspace, WorkspaceId,
|
||||||
|
};
|
||||||
|
use tc_llm::ScriptedProvider;
|
||||||
|
use tc_runtime::{RunEventBody, Runtime, RuntimeConfig};
|
||||||
|
use tc_safety::{approvals, Decision, ResumeReady};
|
||||||
|
|
||||||
|
const SCENARIOS: &str = r#"
|
||||||
|
[[scenario]]
|
||||||
|
marker = "[[scenario:gated-email]]"
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "tool_use", name = "email.send", input = { to = "[email protected]", subject = "Soak", body = "Load test." } },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[scenario.turns]]
|
||||||
|
events = [
|
||||||
|
{ type = "text", text = "Done." },
|
||||||
|
]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const RUNS: usize = 12;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_gated_runs_never_execute_unaudited_or_twice() {
|
||||||
|
let pool = tc_testkit::test_pool().await;
|
||||||
|
let ws = Workspace {
|
||||||
|
id: WorkspaceId::new(),
|
||||||
|
name: "Acme".into(),
|
||||||
|
plan: "team".into(),
|
||||||
|
};
|
||||||
|
tc_db::repo::workspaces::insert(&pool, &ws).await.unwrap();
|
||||||
|
let owner = User {
|
||||||
|
id: UserId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
email: format!("{}@acme.test", UserId::new()),
|
||||||
|
role: Role::Owner,
|
||||||
|
display_name: "Owner".into(),
|
||||||
|
created_at: time::OffsetDateTime::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
tc_db::repo::users::insert(&pool, &owner).await.unwrap();
|
||||||
|
let agent = Agent {
|
||||||
|
id: AgentId::new(),
|
||||||
|
workspace_id: ws.id,
|
||||||
|
name: "Scout".into(),
|
||||||
|
job_title: "Analyst".into(),
|
||||||
|
system_prompt: String::new(),
|
||||||
|
avatar: String::new(),
|
||||||
|
accent: String::new(),
|
||||||
|
wallpaper: String::new(),
|
||||||
|
managed_by: owner.id,
|
||||||
|
status: AgentStatus::Online,
|
||||||
|
};
|
||||||
|
tc_db::repo::agents::insert(&pool, &agent, &AccessPolicy::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tc_db::repo::credits::add_lot(&pool, ws.id, 10_000, "soak")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rt = Runtime::new(
|
||||||
|
pool.clone(),
|
||||||
|
Arc::new(ScriptedProvider::from_toml(SCENARIOS).unwrap()),
|
||||||
|
RuntimeConfig::basic("scripted", 1024),
|
||||||
|
);
|
||||||
|
// The durable sweeper races our explicit resume calls on purpose.
|
||||||
|
rt.spawn_resume_sweeper(Duration::from_millis(25));
|
||||||
|
|
||||||
|
// Launch all runs concurrently, each in its own session.
|
||||||
|
let mut run_ids = Vec::new();
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 0..RUNS {
|
||||||
|
let session = tc_db::repo::sessions::create(&pool, agent.id, ws.id, &format!("Soak {i}"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rt = rt.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let started = rt
|
||||||
|
.send_message(session.id, "send it [[scenario:gated-email]]")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let mut rx = started.events;
|
||||||
|
while let Ok(envelope) = rx.recv().await {
|
||||||
|
if matches!(
|
||||||
|
envelope.event,
|
||||||
|
RunEventBody::RunSuspended { .. } | RunEventBody::Error { .. }
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
started.run_id
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for handle in handles {
|
||||||
|
run_ids.push(handle.await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// All suspended with pending approvals.
|
||||||
|
let pending = approvals::list_pending(&pool, ws.id).await.unwrap();
|
||||||
|
assert_eq!(pending.len(), RUNS);
|
||||||
|
let outbox_before = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outbox_before, 0, "nothing may execute while pending");
|
||||||
|
|
||||||
|
// Decide concurrently: even-indexed approve, odd-indexed reject — and
|
||||||
|
// every decision is attempted TWICE concurrently (double-click storm),
|
||||||
|
// plus an explicit duplicate resume racing the sweeper.
|
||||||
|
let mut deciders = Vec::new();
|
||||||
|
for (i, approval) in pending.iter().enumerate() {
|
||||||
|
let decision = if i % 2 == 0 {
|
||||||
|
Decision::Approve
|
||||||
|
} else {
|
||||||
|
Decision::Reject
|
||||||
|
};
|
||||||
|
for _ in 0..2 {
|
||||||
|
let pool = pool.clone();
|
||||||
|
let rt = rt.clone();
|
||||||
|
let id = approval.id;
|
||||||
|
let run_id = approval.run_id;
|
||||||
|
let user = owner.id;
|
||||||
|
deciders.push(tokio::spawn(async move {
|
||||||
|
let decided = approvals::decide(&pool, id, user, decision).await;
|
||||||
|
// Exactly one of the two concurrent attempts wins.
|
||||||
|
let _ = rt
|
||||||
|
.resume_run(ResumeReady {
|
||||||
|
run_id,
|
||||||
|
approval_id: id,
|
||||||
|
approved: decision == Decision::Approve,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
decided.is_ok()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut wins = 0;
|
||||||
|
for decider in deciders {
|
||||||
|
if decider.await.unwrap() {
|
||||||
|
wins += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(wins, RUNS, "each approval decided exactly once");
|
||||||
|
|
||||||
|
// Every run reaches Completed.
|
||||||
|
for run_id in &run_ids {
|
||||||
|
let mut done = false;
|
||||||
|
for _ in 0..200 {
|
||||||
|
let run = tc_db::repo::runs::get(&pool, *run_id).await.unwrap();
|
||||||
|
if run.state == RunState::Completed {
|
||||||
|
done = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||||
|
}
|
||||||
|
assert!(done, "run {run_id} never completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE invariant: executions == approvals approved, exactly.
|
||||||
|
let approved = RUNS / 2;
|
||||||
|
let outbox = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM outbox")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
outbox as usize, approved,
|
||||||
|
"execute once per approval, never more"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every approved grant consumed exactly once; rejected ones have none.
|
||||||
|
let consumed =
|
||||||
|
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM execution_grants WHERE consumed = true")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let total_grants = sqlx::query_scalar::<_, i64>("SELECT count(*) FROM execution_grants")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(consumed as usize, approved);
|
||||||
|
assert_eq!(total_grants as usize, approved);
|
||||||
|
|
||||||
|
// Every decision audited.
|
||||||
|
let audited = sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT count(*) FROM audit_log WHERE event_type IN ('approval.approved', 'approval.rejected')",
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(audited as usize, RUNS);
|
||||||
|
|
||||||
|
// No approval left pending.
|
||||||
|
assert!(approvals::list_pending(&pool, ws.id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ pub async fn test_pool() -> PgPool {
|
|||||||
|
|
||||||
let test_url = swap_database(&server.admin_url, &db_name);
|
let test_url = swap_database(&server.admin_url, &db_name);
|
||||||
let pool = PgPoolOptions::new()
|
let pool = PgPoolOptions::new()
|
||||||
.max_connections(5)
|
.max_connections(20)
|
||||||
.connect(&test_url)
|
.connect(&test_url)
|
||||||
.await
|
.await
|
||||||
.expect("connect test database");
|
.expect("connect test database");
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
Q2 revenue is up 14%.
|
||||||
Generated
+24
@@ -17,6 +17,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.11.3",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -54,6 +55,29 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@axe-core/playwright": {
|
||||||
|
"version": "4.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz",
|
||||||
|
"integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"axe-core": "~4.11.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright-core": ">= 1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@axe-core/playwright/node_modules/axe-core": {
|
||||||
|
"version": "4.11.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz",
|
||||||
|
"integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.11.3",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -154,10 +154,10 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
|
|||||||
<p className="text-sm font-medium">{live.name}</p>
|
<p className="text-sm font-medium">{live.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{live.job_title}</p>
|
<p className="text-xs text-muted-foreground">{live.job_title}</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="rounded-(--radius) border border-border bg-subtle text-xs">
|
<div className="rounded-(--radius) border border-border bg-subtle text-xs">
|
||||||
<div className="flex justify-between border-b border-border px-2 py-2">
|
<div className="flex justify-between border-b border-border px-2 py-2">
|
||||||
<dt className="text-muted-foreground">Managed by</dt>
|
<span className="text-muted-foreground">Managed by</span>
|
||||||
<dd>{settings.data?.managed_by_name ?? "…"}</dd>
|
<span>{settings.data?.managed_by_name ?? "…"}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -166,7 +166,7 @@ export default function SettingsApp({ agent }: { agent: Agent }) {
|
|||||||
>
|
>
|
||||||
Edit profile <span aria-hidden>›</span>
|
Edit profile <span aria-hidden>›</span>
|
||||||
</button>
|
</button>
|
||||||
</dl>
|
</div>
|
||||||
<p className="pt-1 text-xxs uppercase tracking-wide text-muted-foreground">
|
<p className="pt-1 text-xxs uppercase tracking-wide text-muted-foreground">
|
||||||
Who else has access
|
Who else has access
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ export function CreateClawForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col gap-4">
|
<div className="flex w-full max-w-md flex-col gap-4">
|
||||||
{/* Step progress (§9). */}
|
{/* Step progress (§9). */}
|
||||||
<div className="flex gap-1.5" aria-label={`Step ${stepIndex + 1} of 3`}>
|
<div
|
||||||
|
role="group"
|
||||||
|
className="flex gap-1.5"
|
||||||
|
aria-label={`Step ${stepIndex + 1} of 3`}
|
||||||
|
>
|
||||||
{WIZARD_STEPS.map((s, i) => (
|
{WIZARD_STEPS.map((s, i) => (
|
||||||
<span
|
<span
|
||||||
key={s}
|
key={s}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
// P6 exit (spec §17): a11y sign-off. Serious and critical axe violations
|
||||||
|
// fail the build on every primary surface.
|
||||||
|
|
||||||
|
const OWNER_EMAIL = "[email protected]";
|
||||||
|
const OWNER_PASSWORD = "e2e-password";
|
||||||
|
|
||||||
|
async function signIn(page: Page) {
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByLabel("Email").fill(OWNER_EMAIL);
|
||||||
|
await page.getByLabel("Password").fill(OWNER_PASSWORD);
|
||||||
|
await page.getByRole("button", { name: "Sign in" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "TeamClaw" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectClean(page: Page, context: string) {
|
||||||
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
|
const blocking = results.violations.filter(
|
||||||
|
(v) => v.impact === "serious" || v.impact === "critical",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
blocking.map((v) => `${v.id}: ${v.help} (${v.nodes.length} nodes)`),
|
||||||
|
`axe violations on ${context}`,
|
||||||
|
).toEqual([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("login page is clean", async ({ page }) => {
|
||||||
|
await page.goto("/login");
|
||||||
|
await expect(page.getByRole("button", { name: "Sign in" })).toBeVisible();
|
||||||
|
await expectClean(page, "login");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shell, chat, and computer panel are clean", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await expectClean(page, "workspace home");
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: /Scout/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/claws\/.+\/chat\//);
|
||||||
|
await expectClean(page, "chat");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Computer" }).click();
|
||||||
|
const panel = page.getByRole("complementary", { name: "Computer" });
|
||||||
|
await expect(panel.getByText(/Scout's Computer/)).toBeVisible();
|
||||||
|
await expectClean(page, "computer home");
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Settings" }).click();
|
||||||
|
await expect(panel.getByText("Managed by")).toBeVisible();
|
||||||
|
await expectClean(page, "settings app");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global pages are clean", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
for (const [link, marker] of [
|
||||||
|
["Skills", /Skill Library/],
|
||||||
|
["Approvals", /review/i],
|
||||||
|
["Team", /workspace/],
|
||||||
|
["Credits", /Available credits/],
|
||||||
|
] as const) {
|
||||||
|
await page.getByRole("link", { name: link, exact: true }).click();
|
||||||
|
await expect(page.getByText(marker).first()).toBeVisible();
|
||||||
|
await expectClean(page, link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the wizard is clean", async ({ page }) => {
|
||||||
|
await signIn(page);
|
||||||
|
await page.getByRole("link", { name: "New claw" }).click();
|
||||||
|
await expect(page.getByText("Create your Claw")).toBeVisible();
|
||||||
|
await expectClean(page, "wizard");
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "teamclaw-bundler"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
hex = "0.4"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sha2 = "0.10"
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//! Air-gapped bundle assembly and offline verification.
|
||||||
|
//!
|
||||||
|
//! A bundle is a directory of artifacts (image tarballs, compose files,
|
||||||
|
//! migrations, models) plus `manifest.json`, `checksums.txt` (sha256 per
|
||||||
|
//! artifact), and `checksums.sig` — a detached ed25519 signature over the
|
||||||
|
//! checksum file. Verification needs only the public key: no network, no
|
||||||
|
//! transparency log (keyless signing is internet-dependent and therefore
|
||||||
|
//! disqualified for this target).
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BundleError {
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("bad key material: {0}")]
|
||||||
|
Key(String),
|
||||||
|
#[error("signature verification failed")]
|
||||||
|
BadSignature,
|
||||||
|
#[error("checksum mismatch for {0}")]
|
||||||
|
ChecksumMismatch(String),
|
||||||
|
#[error("bundle is missing {0}")]
|
||||||
|
Missing(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn io_err(e: std::io::Error) -> BundleError {
|
||||||
|
BundleError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a signing keypair; the private key stays with the release
|
||||||
|
/// pipeline, the public key ships to customers out of band.
|
||||||
|
pub fn generate_keypair(private_path: &Path, public_path: &Path) -> Result<(), BundleError> {
|
||||||
|
let signing = SigningKey::generate(&mut rand_core::OsRng);
|
||||||
|
fs::write(private_path, hex::encode(signing.to_bytes())).map_err(io_err)?;
|
||||||
|
fs::write(public_path, hex::encode(signing.verifying_key().to_bytes())).map_err(io_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_signing_key(path: &Path) -> Result<SigningKey, BundleError> {
|
||||||
|
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
|
||||||
|
.map_err(|e| BundleError::Key(e.to_string()))?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BundleError::Key("private key must be 32 bytes".into()))?;
|
||||||
|
Ok(SigningKey::from_bytes(&bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_verifying_key(path: &Path) -> Result<VerifyingKey, BundleError> {
|
||||||
|
let bytes: [u8; 32] = hex::decode(fs::read_to_string(path).map_err(io_err)?.trim())
|
||||||
|
.map_err(|e| BundleError::Key(e.to_string()))?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BundleError::Key("public key must be 32 bytes".into()))?;
|
||||||
|
VerifyingKey::from_bytes(&bytes).map_err(|e| BundleError::Key(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_file(path: &Path) -> Result<String, BundleError> {
|
||||||
|
let bytes = fs::read(path).map_err(io_err)?;
|
||||||
|
Ok(hex::encode(Sha256::digest(&bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies artifacts into `out`, writes the manifest, checksums every file,
|
||||||
|
/// and signs the checksum list. `artifacts` are (source, bundle-relative
|
||||||
|
/// destination) pairs.
|
||||||
|
pub fn assemble(
|
||||||
|
out: &Path,
|
||||||
|
version: &str,
|
||||||
|
artifacts: &[(PathBuf, String)],
|
||||||
|
private_key: &Path,
|
||||||
|
) -> Result<(), BundleError> {
|
||||||
|
fs::create_dir_all(out).map_err(io_err)?;
|
||||||
|
let mut entries: Vec<(String, String)> = Vec::new();
|
||||||
|
for (source, destination) in artifacts {
|
||||||
|
let target = out.join(destination);
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(io_err)?;
|
||||||
|
}
|
||||||
|
fs::copy(source, &target).map_err(io_err)?;
|
||||||
|
entries.push((destination.clone(), sha256_file(&target)?));
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"name": "teamclaw",
|
||||||
|
"version": version,
|
||||||
|
"artifacts": entries.iter().map(|(p, h)| {
|
||||||
|
serde_json::json!({"path": p, "sha256": h})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
let manifest_path = out.join("manifest.json");
|
||||||
|
fs::write(
|
||||||
|
&manifest_path,
|
||||||
|
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||||
|
)
|
||||||
|
.map_err(io_err)?;
|
||||||
|
entries.push(("manifest.json".into(), sha256_file(&manifest_path)?));
|
||||||
|
|
||||||
|
let checksums: String = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(path, hash)| format!("{hash} {path}\n"))
|
||||||
|
.collect();
|
||||||
|
fs::write(out.join("checksums.txt"), &checksums).map_err(io_err)?;
|
||||||
|
|
||||||
|
let key = load_signing_key(private_key)?;
|
||||||
|
let signature: Signature = key.sign(checksums.as_bytes());
|
||||||
|
fs::write(out.join("checksums.sig"), hex::encode(signature.to_bytes())).map_err(io_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fully offline verification: the signature over `checksums.txt`, then
|
||||||
|
/// every listed artifact hash. This is what `install.sh` runs before any
|
||||||
|
/// `docker load`.
|
||||||
|
pub fn verify(bundle: &Path, public_key: &Path) -> Result<usize, BundleError> {
|
||||||
|
let checksums_path = bundle.join("checksums.txt");
|
||||||
|
if !checksums_path.is_file() {
|
||||||
|
return Err(BundleError::Missing("checksums.txt".into()));
|
||||||
|
}
|
||||||
|
let checksums = fs::read_to_string(&checksums_path).map_err(io_err)?;
|
||||||
|
|
||||||
|
let signature_hex = fs::read_to_string(bundle.join("checksums.sig"))
|
||||||
|
.map_err(|_| BundleError::Missing("checksums.sig".into()))?;
|
||||||
|
let signature_bytes: [u8; 64] = hex::decode(signature_hex.trim())
|
||||||
|
.map_err(|e| BundleError::Key(e.to_string()))?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| BundleError::Key("signature must be 64 bytes".into()))?;
|
||||||
|
let key = load_verifying_key(public_key)?;
|
||||||
|
key.verify(
|
||||||
|
checksums.as_bytes(),
|
||||||
|
&Signature::from_bytes(&signature_bytes),
|
||||||
|
)
|
||||||
|
.map_err(|_| BundleError::BadSignature)?;
|
||||||
|
|
||||||
|
let mut verified = 0;
|
||||||
|
for line in checksums.lines() {
|
||||||
|
let Some((expected, path)) = line.split_once(" ") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let actual =
|
||||||
|
sha256_file(&bundle.join(path)).map_err(|_| BundleError::Missing(path.to_owned()))?;
|
||||||
|
if actual != expected {
|
||||||
|
return Err(BundleError::ChecksumMismatch(path.to_owned()));
|
||||||
|
}
|
||||||
|
verified += 1;
|
||||||
|
}
|
||||||
|
Ok(verified)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! teamclaw-bundler: assemble and verify air-gapped install bundles.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! teamclaw-bundler keygen <private.key> <public.key>
|
||||||
|
//! teamclaw-bundler assemble <out-dir> <version> <private.key> \
|
||||||
|
//! <src=dest> [<src=dest> ...]
|
||||||
|
//! teamclaw-bundler verify <bundle-dir> <public.key>
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(message) => {
|
||||||
|
println!("{message}");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
eprintln!("teamclaw-bundler: {message}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<String, String> {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
match args.first().map(String::as_str) {
|
||||||
|
Some("keygen") if args.len() == 3 => {
|
||||||
|
teamclaw_bundler::generate_keypair(Path::new(&args[1]), Path::new(&args[2]))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("wrote {} and {}", args[1], args[2]))
|
||||||
|
}
|
||||||
|
Some("assemble") if args.len() >= 5 => {
|
||||||
|
let artifacts: Vec<(PathBuf, String)> = args[4..]
|
||||||
|
.iter()
|
||||||
|
.map(|pair| {
|
||||||
|
pair.split_once('=')
|
||||||
|
.map(|(src, dst)| (PathBuf::from(src), dst.to_owned()))
|
||||||
|
.ok_or_else(|| format!("expected src=dest, got {pair}"))
|
||||||
|
})
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
teamclaw_bundler::assemble(
|
||||||
|
Path::new(&args[1]),
|
||||||
|
&args[2],
|
||||||
|
&artifacts,
|
||||||
|
Path::new(&args[3]),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!(
|
||||||
|
"assembled {} artifacts into {}",
|
||||||
|
artifacts.len(),
|
||||||
|
args[1]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Some("verify") if args.len() == 3 => {
|
||||||
|
let verified =
|
||||||
|
teamclaw_bundler::verify(Path::new(&args[1]), Path::new(&args[2]))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("bundle OK: {verified} artifacts verified offline"))
|
||||||
|
}
|
||||||
|
_ => Err("usage: keygen <priv> <pub> | assemble <out> <version> <priv> <src=dest>... | verify <bundle> <pub>".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use teamclaw_bundler::{assemble, generate_keypair, verify, BundleError};
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
root: PathBuf,
|
||||||
|
public_key: PathBuf,
|
||||||
|
bundle: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> Fixture {
|
||||||
|
let root = std::env::temp_dir().join(format!("tc-bundle-{}", uuid::Uuid::now_v7()));
|
||||||
|
fs::create_dir_all(&root).unwrap();
|
||||||
|
let private_key = root.join("release.key");
|
||||||
|
let public_key = root.join("release.pub");
|
||||||
|
generate_keypair(&private_key, &public_key).unwrap();
|
||||||
|
|
||||||
|
fs::write(root.join("server.tar"), b"pretend image bytes").unwrap();
|
||||||
|
fs::write(root.join("compose.yml"), b"services: {}").unwrap();
|
||||||
|
fs::write(root.join("0001_init.sql"), b"CREATE TABLE t ();").unwrap();
|
||||||
|
|
||||||
|
let bundle = root.join("bundle");
|
||||||
|
assemble(
|
||||||
|
&bundle,
|
||||||
|
"1.2.3",
|
||||||
|
&[
|
||||||
|
(root.join("server.tar"), "images/server.tar".into()),
|
||||||
|
(
|
||||||
|
root.join("compose.yml"),
|
||||||
|
"compose/docker-compose.yml".into(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
root.join("0001_init.sql"),
|
||||||
|
"migrations/0001_init.sql".into(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
&private_key,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Fixture {
|
||||||
|
root,
|
||||||
|
public_key,
|
||||||
|
bundle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assembled_bundles_verify_offline() {
|
||||||
|
let fx = fixture();
|
||||||
|
// 3 artifacts + manifest.json.
|
||||||
|
assert_eq!(verify(&fx.bundle, &fx.public_key).unwrap(), 4);
|
||||||
|
|
||||||
|
let manifest: serde_json::Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(fx.bundle.join("manifest.json")).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(manifest["version"], "1.2.3");
|
||||||
|
assert_eq!(manifest["artifacts"].as_array().unwrap().len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tampered_artifact_is_detected() {
|
||||||
|
let fx = fixture();
|
||||||
|
fs::write(fx.bundle.join("images/server.tar"), b"EVIL image bytes").unwrap();
|
||||||
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
||||||
|
assert!(matches!(err, BundleError::ChecksumMismatch(p) if p == "images/server.tar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tampered_checksum_list_fails_the_signature() {
|
||||||
|
let fx = fixture();
|
||||||
|
// Attacker edits both the artifact AND its checksum line — the
|
||||||
|
// signature over checksums.txt catches it.
|
||||||
|
let listing = fs::read_to_string(fx.bundle.join("checksums.txt")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
fx.bundle.join("checksums.txt"),
|
||||||
|
listing.replace("images/server.tar", "images/sneaky.tar"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
||||||
|
assert!(matches!(err, BundleError::BadSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_public_key_is_refused() {
|
||||||
|
let fx = fixture();
|
||||||
|
let other_private = fx.root.join("other.key");
|
||||||
|
let other_public = fx.root.join("other.pub");
|
||||||
|
generate_keypair(&other_private, &other_public).unwrap();
|
||||||
|
let err = verify(&fx.bundle, &other_public).unwrap_err();
|
||||||
|
assert!(matches!(err, BundleError::BadSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_artifact_is_reported() {
|
||||||
|
let fx = fixture();
|
||||||
|
fs::remove_file(fx.bundle.join("migrations/0001_init.sql")).unwrap();
|
||||||
|
let err = verify(&fx.bundle, &fx.public_key).unwrap_err();
|
||||||
|
assert!(matches!(err, BundleError::Missing(p) if p.contains("0001_init.sql")));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user