Skip to content

Commit d3308b9

Browse files
Jonathan D.A. Jewellclaude
andcommitted
feat: integration tests, tooling enrollment, packaging, k9 contractiles
Add yacht-agent integration tests (4 tests: mooring e2e, health, metrics, stats). Fix MooringClient null-error handling bug where `error: null` in JSON responses was incorrectly treated as an error. Extract build_api_router() for testability. Add systemd unit file for bare-metal yacht-agent deployment with full security hardening (DynamicUser, ProtectSystem=strict, etc.). Enroll in panic-attacker (security-scan.yml workflow), echidna and gitbot-fleet (8 bot directives with wharf-specific constraints). Fill k9 contractile templates: Kennel (yacht-config), Yard (deployment params with validation contracts), Hunt (deploy automation). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c7073f7 commit d3308b9

15 files changed

Lines changed: 756 additions & 188 deletions

File tree

.bot_directives/echidnabot.scm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
(bot "echidnabot")
44
(scope "formal verification and fuzzing")
55
(allow ("analysis" "fuzzing" "proof checks"))
6-
(deny ("write to core modules" "write to bindings"))
7-
(notes "May open findings; code changes require explicit approval"))
6+
(deny ("write to crypto module" "write to mooring protocol"))
7+
(notes "May open findings; code changes to crypto.rs or mooring.rs require explicit approval"))

.bot_directives/seambot.scm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
(scope "integration health")
55
(allow ("analysis" "contract checks" "docs updates"))
66
(deny ("code changes without approval"))
7-
(notes "May add integration test suggestions"))
7+
(notes "Verify mooring protocol contracts and inter-crate API consistency"))
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Panic-Attack Security Scan — Universal stress testing + VeriSimDB ingest
3+
name: Security Scan
4+
5+
on:
6+
push:
7+
branches: [main]
8+
schedule:
9+
- cron: '0 0 * * 0' # Weekly on Sunday
10+
workflow_dispatch:
11+
12+
permissions: read-all
13+
14+
jobs:
15+
panic-attack:
16+
name: Panic-Attack Assail
17+
runs-on: ubuntu-latest
18+
19+
steps:
20+
- name: Checkout repository
21+
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
22+
with:
23+
fetch-depth: 0
24+
25+
- name: Install Rust toolchain
26+
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable
27+
with:
28+
toolchain: stable
29+
30+
- name: Cache panic-attacker binary
31+
id: cache-pa
32+
uses: actions/cache@v4
33+
with:
34+
path: ~/.cargo/bin/panic-attacker
35+
key: panic-attacker-${{ runner.os }}
36+
37+
- name: Build panic-attacker
38+
if: steps.cache-pa.outputs.cache-hit != 'true'
39+
run: |
40+
git clone https://github.com/hyperpolymath/panic-attacker.git /tmp/panic-attacker
41+
cd /tmp/panic-attacker
42+
cargo build --release
43+
cp target/release/panic-attacker ~/.cargo/bin/
44+
45+
- name: Run panic-attack assail
46+
run: |
47+
panic-attacker assail . --output /tmp/scan-results.json
48+
echo "## Panic-Attack Scan Results" >> $GITHUB_STEP_SUMMARY
49+
WEAK_POINTS=$(jq '.weak_points | length' /tmp/scan-results.json 2>/dev/null || echo 0)
50+
echo "- Weak points found: $WEAK_POINTS" >> $GITHUB_STEP_SUMMARY
51+
52+
- name: Upload scan artifact
53+
uses: actions/upload-artifact@65c79d7f54e76e4e3c7a8f34db0f4ac8b515c478 # v4
54+
with:
55+
name: panic-attack-results
56+
path: /tmp/scan-results.json
57+
retention-days: 90
58+
59+
- name: Ingest to VeriSimDB
60+
if: always()
61+
env:
62+
VERISIMDB_PAT: ${{ secrets.VERISIMDB_PAT }}
63+
run: |
64+
if [ -z "$VERISIMDB_PAT" ]; then
65+
echo "VERISIMDB_PAT not set — skipping VeriSimDB ingest"
66+
exit 0
67+
fi
68+
69+
curl -X POST \
70+
-H "Authorization: token $VERISIMDB_PAT" \
71+
-H "Accept: application/vnd.github.v3+json" \
72+
https://api.github.com/repos/hyperpolymath/verisimdb-data/dispatches \
73+
-d "{\"event_type\":\"ingest-scan\",\"client_payload\":{\"repo\":\"project-wharf\",\"scan_file\":\"panic-attack-results\"}}"
74+
75+
echo "Dispatched VeriSimDB ingest event"

.gitignore

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ __pycache__/
4949
# Ada/SPARK
5050
*.ali
5151
/obj/
52-
/bin/
52+
# /bin/ — NOT ignored: contains wharf-cli and yacht-agent source crates
5353

5454
# Haskell
5555
/.stack-work/
@@ -77,3 +77,8 @@ htmlcov/
7777
/tmp/
7878
*.tmp
7979
*.bak
80+
81+
# AI/Sync artifacts
82+
ai-cli-crash-capture/
83+
.directory
84+
sync_report*.txt

AI.a2ml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424
- AST-aware SQL proxy blocks injection at wire level
2525
- Fail-closed: if it can't verify, it blocks
2626

27+
## Integration
28+
29+
- **panic-attack**: Weekly security scan via `.github/workflows/security-scan.yml`
30+
- **hypatia**: Neurosymbolic CI/CD via `.github/workflows/hypatia-scan.yml`
31+
- **gitbot-fleet**: Bot directives in `.bot_directives/` (8 bots enrolled)
32+
- **verisimdb-data**: Scan results ingested via repository_dispatch
33+
- **echidna**: Formal verification via echidnabot directive
34+
2735
## Delivery Promises
2836
- Mention in summaries whether STATE, `contractiles/`, or `.bot_directives/` changed.
2937
- Keep this file in sync with the repository's status; update it if the governance changes.

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bin/yacht-agent/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,6 @@ anyhow = { workspace = true }
4343

4444
# eBPF Userspace Loader (optional - only used in ebpf mode)
4545
aya = { workspace = true }
46+
47+
[dev-dependencies]
48+
reqwest = { workspace = true }

bin/yacht-agent/src/main.rs

Lines changed: 196 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -471,24 +471,11 @@ async fn main() -> anyhow::Result<()> {
471471
});
472472

473473
// Build the API router
474-
let api_state = state.clone();
475-
let mut app = Router::new()
476-
.route("/health", get(health_check))
477-
.route("/status", get(status))
478-
.route("/stats", get(stats))
479-
// Mooring protocol endpoints
480-
.route("/mooring/init", post(mooring_init))
481-
.route("/mooring/verify", post(mooring_verify))
482-
.route("/mooring/commit", post(mooring_commit));
483-
484-
// Add metrics endpoint if enabled
474+
let app = build_api_router(state.clone(), args.metrics_enabled);
485475
if args.metrics_enabled {
486-
app = app.route("/metrics", get(prometheus_metrics));
487476
info!("Prometheus metrics enabled at /metrics");
488477
}
489478

490-
let app = app.with_state(api_state);
491-
492479
// Bind API to localhost only (Nebula mesh provides external access)
493480
let api_addr = SocketAddr::from(([0, 0, 0, 0], args.api_port));
494481
info!("API listening on {}", api_addr);
@@ -499,6 +486,29 @@ async fn main() -> anyhow::Result<()> {
499486
Ok(())
500487
}
501488

489+
// =============================================================================
490+
// ROUTER CONSTRUCTION
491+
// =============================================================================
492+
493+
/// Build the API router for the yacht-agent.
494+
///
495+
/// Extracted for testability — the integration tests call this directly.
496+
fn build_api_router(state: SharedState, metrics_enabled: bool) -> Router {
497+
let mut app = Router::new()
498+
.route("/health", get(health_check))
499+
.route("/status", get(status))
500+
.route("/stats", get(stats))
501+
.route("/mooring/init", post(mooring_init))
502+
.route("/mooring/verify", post(mooring_verify))
503+
.route("/mooring/commit", post(mooring_commit));
504+
505+
if metrics_enabled {
506+
app = app.route("/metrics", get(prometheus_metrics));
507+
}
508+
509+
app.with_state(state)
510+
}
511+
502512
// =============================================================================
503513
// DATABASE PROXY
504514
// =============================================================================
@@ -1299,3 +1309,175 @@ async fn setup_nftables_firewall() -> Option<NftablesManager> {
12991309
}
13001310
}
13011311
}
1312+
1313+
// =============================================================================
1314+
// INTEGRATION TESTS
1315+
// =============================================================================
1316+
1317+
#[cfg(test)]
1318+
mod tests {
1319+
use super::*;
1320+
use std::collections::HashMap;
1321+
use wharf_core::mooring::{LayerManifest, MooringLayer};
1322+
use wharf_core::mooring_client::MooringClient;
1323+
1324+
/// End-to-end test of the full mooring protocol flow:
1325+
/// init → verify → commit over real HTTP.
1326+
#[tokio::test]
1327+
async fn test_mooring_e2e_flow() {
1328+
// Build the yacht-agent API on a random port
1329+
let state = Arc::new(RwLock::new(AgentState::new(None)));
1330+
let app = build_api_router(state.clone(), true);
1331+
1332+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1333+
.await
1334+
.expect("Failed to bind test listener");
1335+
let addr = listener.local_addr().unwrap();
1336+
1337+
tokio::spawn(async move {
1338+
axum::serve(listener, app).await.unwrap();
1339+
});
1340+
1341+
// Give the server a moment to start
1342+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1343+
1344+
// Create a MooringClient with a fresh keypair
1345+
let keypair = generate_hybrid_keypair().expect("keypair gen");
1346+
let client = MooringClient::new(&format!("http://{}", addr), keypair);
1347+
1348+
// Step 1: Init mooring session
1349+
let layers = vec![MooringLayer::Config, MooringLayer::Files];
1350+
let init_resp = client
1351+
.init_session(layers, false, false)
1352+
.await
1353+
.expect("init_session failed");
1354+
1355+
assert!(!init_resp.session_id.is_empty(), "session_id should not be empty");
1356+
assert_eq!(init_resp.accepted_layers.len(), 2);
1357+
assert!(init_resp.expires_at > 0);
1358+
1359+
// Step 2: Verify a layer
1360+
let manifest = LayerManifest {
1361+
files: HashMap::from([
1362+
("index.html".to_string(), "abc123".to_string()),
1363+
("style.css".to_string(), "def456".to_string()),
1364+
]),
1365+
total_size: 2048,
1366+
file_count: 2,
1367+
root_hash: "root000".to_string(),
1368+
};
1369+
1370+
let verify_resp = client
1371+
.verify_layer(&init_resp.session_id, MooringLayer::Config, manifest)
1372+
.await
1373+
.expect("verify_layer failed");
1374+
1375+
// Without site_root configured, verification passes (dev mode)
1376+
assert!(verify_resp.verified);
1377+
assert_eq!(verify_resp.matched_files, 2);
1378+
1379+
// Step 3: Commit
1380+
let commit_resp = client
1381+
.commit(&init_resp.session_id, init_resp.accepted_layers)
1382+
.await
1383+
.expect("commit failed");
1384+
1385+
assert!(commit_resp.success);
1386+
assert!(commit_resp.snapshot_id.is_some());
1387+
1388+
// Verify state was updated
1389+
let s = state.read().await;
1390+
assert!(s.moored);
1391+
assert!(s.last_mooring_time.is_some());
1392+
assert_eq!(s.mooring_session_count, 1);
1393+
}
1394+
1395+
/// Test that the health endpoint responds
1396+
#[tokio::test]
1397+
async fn test_health_endpoint() {
1398+
let state = Arc::new(RwLock::new(AgentState::new(None)));
1399+
let app = build_api_router(state, false);
1400+
1401+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1402+
.await
1403+
.unwrap();
1404+
let addr = listener.local_addr().unwrap();
1405+
1406+
tokio::spawn(async move {
1407+
axum::serve(listener, app).await.unwrap();
1408+
});
1409+
1410+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1411+
1412+
let resp = reqwest::get(format!("http://{}/health", addr))
1413+
.await
1414+
.expect("health request failed");
1415+
1416+
assert!(resp.status().is_success());
1417+
assert_eq!(resp.text().await.unwrap(), "OK");
1418+
}
1419+
1420+
/// Test that metrics endpoint returns real counters
1421+
#[tokio::test]
1422+
async fn test_metrics_endpoint() {
1423+
let state = Arc::new(RwLock::new(AgentState::new(None)));
1424+
{
1425+
let mut s = state.write().await;
1426+
s.queries_allowed = 42;
1427+
s.queries_blocked = 3;
1428+
}
1429+
let app = build_api_router(state, true);
1430+
1431+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1432+
.await
1433+
.unwrap();
1434+
let addr = listener.local_addr().unwrap();
1435+
1436+
tokio::spawn(async move {
1437+
axum::serve(listener, app).await.unwrap();
1438+
});
1439+
1440+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1441+
1442+
let resp = reqwest::get(format!("http://{}/metrics", addr))
1443+
.await
1444+
.expect("metrics request failed");
1445+
1446+
let body = resp.text().await.unwrap();
1447+
assert!(body.contains("yacht_queries_total{status=\"allowed\"} 42"));
1448+
assert!(body.contains("yacht_queries_total{status=\"blocked\"} 3"));
1449+
}
1450+
1451+
/// Test that stats endpoint returns real counters
1452+
#[tokio::test]
1453+
async fn test_stats_endpoint() {
1454+
let state = Arc::new(RwLock::new(AgentState::new(None)));
1455+
{
1456+
let mut s = state.write().await;
1457+
s.queries_allowed = 10;
1458+
s.queries_blocked = 5;
1459+
s.queries_audited = 2;
1460+
}
1461+
let app = build_api_router(state, false);
1462+
1463+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1464+
.await
1465+
.unwrap();
1466+
let addr = listener.local_addr().unwrap();
1467+
1468+
tokio::spawn(async move {
1469+
axum::serve(listener, app).await.unwrap();
1470+
});
1471+
1472+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1473+
1474+
let resp = reqwest::get(format!("http://{}/stats", addr))
1475+
.await
1476+
.expect("stats request failed");
1477+
1478+
let body: serde_json::Value = resp.json().await.unwrap();
1479+
assert_eq!(body["queries"]["allowed"], 10);
1480+
assert_eq!(body["queries"]["blocked"], 5);
1481+
assert_eq!(body["queries"]["audited"], 2);
1482+
}
1483+
}

0 commit comments

Comments
 (0)