Skip to content

Commit 5f0c91d

Browse files
committed
mcp tests
1 parent 650f216 commit 5f0c91d

File tree

6 files changed

+171
-12
lines changed

6 files changed

+171
-12
lines changed

jacs/src/agent/payloads.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,8 @@ impl PayloadTraits for Agent {
8484

8585
// Default payload freshness window: 5 minutes.
8686
// Can be overridden per call, or globally with JACS_PAYLOAD_MAX_REPLAY_SECONDS.
87-
let max_replay_seconds = max_replay_time_delta_seconds
88-
.or_else(|| {
89-
std::env::var("JACS_PAYLOAD_MAX_REPLAY_SECONDS")
90-
.ok()
91-
.and_then(|v| v.parse::<u64>().ok())
92-
})
93-
.unwrap_or(300);
87+
let max_replay_seconds =
88+
max_replay_time_delta_seconds.unwrap_or_else(replay::payload_replay_window_seconds);
9489
let current_time = std::time::SystemTime::now()
9590
.duration_since(std::time::UNIX_EPOCH)?
9691
.as_secs();

jacs/src/replay.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,35 @@ use moka::sync::Cache;
44
use std::sync::LazyLock;
55
use std::time::Duration;
66

7-
const MIN_REPLAY_TTL_SECONDS: i64 = 1;
7+
pub const DEFAULT_PAYLOAD_MAX_REPLAY_SECONDS: u64 = 300;
8+
const MIN_REPLAY_TTL_SECONDS: i64 = DEFAULT_PAYLOAD_MAX_REPLAY_SECONDS as i64;
9+
10+
/// Returns the payload replay window used by payload verification.
11+
///
12+
/// `JACS_PAYLOAD_MAX_REPLAY_SECONDS` overrides the default.
13+
pub fn payload_replay_window_seconds() -> u64 {
14+
std::env::var("JACS_PAYLOAD_MAX_REPLAY_SECONDS")
15+
.ok()
16+
.and_then(|v| v.parse::<u64>().ok())
17+
.unwrap_or(DEFAULT_PAYLOAD_MAX_REPLAY_SECONDS)
18+
}
19+
20+
fn effective_replay_window_seconds() -> i64 {
21+
let payload_window = i64::try_from(payload_replay_window_seconds()).unwrap_or(i64::MAX);
22+
time_utils::max_iat_skew_seconds().max(payload_window)
23+
}
824

925
// Cache seen (scope, nonce) pairs for the active replay window.
1026
static SEEN_NONCES: LazyLock<Cache<String, ()>> = LazyLock::new(|| {
11-
let ttl = time_utils::max_iat_skew_seconds().max(MIN_REPLAY_TTL_SECONDS) as u64;
27+
let ttl = effective_replay_window_seconds().max(MIN_REPLAY_TTL_SECONDS) as u64;
1228
Cache::builder()
1329
.time_to_live(Duration::from_secs(ttl))
1430
.max_capacity(200_000)
1531
.build()
1632
});
1733

1834
fn replay_window_enabled() -> bool {
19-
time_utils::max_iat_skew_seconds() > 0
35+
effective_replay_window_seconds() > 0
2036
}
2137

2238
/// Rejects duplicate nonces observed within the replay window.

jacs/tests/payload_security_tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,29 @@ fn verify_payload_explicit_argument_overrides_env_window() {
9898
.expect("explicit replay window should override strict env setting");
9999
assert_eq!(payload["test"], "explicit-override");
100100
}
101+
102+
#[test]
103+
#[serial]
104+
fn verify_payload_replay_nonce_retention_tracks_payload_window() {
105+
let _guard_payload = EnvVarGuard::set("JACS_PAYLOAD_MAX_REPLAY_SECONDS", "5");
106+
let _guard_iat = EnvVarGuard::set("JACS_MAX_IAT_SKEW_SECONDS", "1");
107+
let mut agent = load_test_agent_one();
108+
let signed = agent
109+
.sign_payload(json!({ "test": "replay-window-alignment" }))
110+
.expect("payload signing should succeed");
111+
112+
agent
113+
.verify_payload(signed.clone(), None)
114+
.expect("first verification should succeed");
115+
116+
std::thread::sleep(Duration::from_secs(2));
117+
118+
let err = agent
119+
.verify_payload(signed, None)
120+
.expect_err("replay nonce should remain blocked for the full payload window");
121+
assert!(
122+
err.to_string().contains("Replay attack detected"),
123+
"unexpected error: {}",
124+
err
125+
);
126+
}

jacsnpm/scripts/install-cli.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const http = require('http');
1212
const fs = require('fs');
1313
const path = require('path');
1414
const os = require('os');
15+
const crypto = require('crypto');
1516
const { execSync } = require('child_process');
1617

1718
const VERSION = require('../package.json').version;
@@ -65,6 +66,50 @@ async function download(url, dest) {
6566
});
6667
}
6768

69+
function sha256File(filePath) {
70+
const hasher = crypto.createHash('sha256');
71+
hasher.update(fs.readFileSync(filePath));
72+
return hasher.digest('hex');
73+
}
74+
75+
function readExpectedSha256(checksumPath, assetName) {
76+
const checksumText = fs.readFileSync(checksumPath, 'utf8').trim();
77+
if (!checksumText) {
78+
throw new Error(`Checksum file was empty: ${checksumPath}`);
79+
}
80+
81+
const lines = checksumText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
82+
for (const line of lines) {
83+
// Format: "<sha256> <filename>" (or optional "*" marker)
84+
let match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/);
85+
if (match) {
86+
const digest = match[1].toLowerCase();
87+
const fileName = path.basename(match[2].trim());
88+
if (fileName === assetName) {
89+
return digest;
90+
}
91+
}
92+
93+
// Format: "SHA256(<filename>)=<sha256>"
94+
match = line.match(/^SHA256\s*\((.+)\)\s*=\s*([a-fA-F0-9]{64})$/i);
95+
if (match) {
96+
const fileName = path.basename(match[1].trim());
97+
const digest = match[2].toLowerCase();
98+
if (fileName === assetName) {
99+
return digest;
100+
}
101+
}
102+
103+
// Format: "<sha256>" (single-line digest file)
104+
match = line.match(/^([a-fA-F0-9]{64})$/);
105+
if (match && lines.length === 1) {
106+
return match[1].toLowerCase();
107+
}
108+
}
109+
110+
throw new Error(`Checksum for ${assetName} not found in ${checksumPath}`);
111+
}
112+
68113
async function main() {
69114
const key = getPlatformKey();
70115
if (!key) {
@@ -76,6 +121,7 @@ async function main() {
76121
const ext = isWindows ? 'zip' : 'tar.gz';
77122
const assetName = `jacs-cli-${VERSION}-${key}.${ext}`;
78123
const url = `https://github.com/${REPO}/releases/download/cli/v${VERSION}/${assetName}`;
124+
const checksumUrl = `${url}.sha256`;
79125

80126
const binDir = getBinDir();
81127
const binPath = path.join(binDir, getBinName());
@@ -90,9 +136,19 @@ async function main() {
90136

91137
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jacs-cli-'));
92138
const archivePath = path.join(tmpDir, assetName);
139+
const checksumPath = path.join(tmpDir, `${assetName}.sha256`);
93140

94141
try {
142+
console.log(`[jacs] Downloading checksum for pinned version ${VERSION} from ${checksumUrl}`);
143+
await download(checksumUrl, checksumPath);
95144
await download(url, archivePath);
145+
const expectedSha256 = readExpectedSha256(checksumPath, assetName);
146+
const actualSha256 = sha256File(archivePath);
147+
if (expectedSha256 !== actualSha256) {
148+
throw new Error(
149+
`Checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}`
150+
);
151+
}
96152

97153
fs.mkdirSync(binDir, { recursive: true });
98154

jacspy/python/jacs/mcp.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,8 +607,14 @@ async def connect_session(self, **session_kwargs):
607607
original_send = original_write_stream.send
608608
async def intercepted_send(message, **send_kwargs):
609609
if agent_ready and isinstance(message.root, dict):
610-
signed = sign_mcp_message(message.root)
611-
message.root = json.loads(signed)
610+
try:
611+
signed = sign_mcp_message(message.root)
612+
message.root = json.loads(signed)
613+
except Exception as e:
614+
LOGGER.warning(
615+
"JACS signing on send failed; forwarding unsigned message: %s",
616+
e,
617+
)
612618
return await original_send(message, **send_kwargs)
613619

614620
original_write_stream.send = intercepted_send

jacspy/tests/test_mcp.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for the JACS MCP integration wrappers."""
22

33
import asyncio
4+
import contextlib
45
import pytest
56
from unittest.mock import Mock, AsyncMock, patch, MagicMock
67
import json
@@ -219,3 +220,62 @@ def test_json_serialization_compatibility(self):
219220
serialized = json.dumps(payload)
220221
deserialized = json.loads(serialized)
221222
assert deserialized == payload
223+
224+
225+
class TestJacsSSETransportBehavior:
226+
"""Behavior checks for JacsSSETransport interceptors."""
227+
228+
def test_send_does_not_fail_when_signing_errors(self, monkeypatch):
229+
from jacs import mcp
230+
231+
sent_payloads = []
232+
233+
class FakeSSETransport:
234+
def __init__(self, url, headers=None):
235+
self.url = url
236+
self.headers = headers
237+
238+
class FakeReadStream:
239+
async def receive(self, **_kwargs):
240+
return SimpleNamespace(root={"jsonrpc": "2.0", "id": 1})
241+
242+
class FakeWriteStream:
243+
async def send(self, message, **_kwargs):
244+
sent_payloads.append(message.root)
245+
246+
read_stream = FakeReadStream()
247+
write_stream = FakeWriteStream()
248+
249+
@contextlib.asynccontextmanager
250+
async def fake_sse_client(_url, headers=None):
251+
yield (read_stream, write_stream)
252+
253+
class FakeClientSession:
254+
def __init__(self, _read_stream, _write_stream, **_kwargs):
255+
pass
256+
257+
async def __aenter__(self):
258+
return self
259+
260+
async def __aexit__(self, _exc_type, _exc, _tb):
261+
return False
262+
263+
async def initialize(self):
264+
return None
265+
266+
monkeypatch.setattr(mcp, "SSETransport", FakeSSETransport)
267+
monkeypatch.setattr(mcp, "sse_client", fake_sse_client)
268+
monkeypatch.setattr(mcp, "ClientSession", FakeClientSession)
269+
monkeypatch.setattr(mcp.simple, "is_loaded", lambda: True)
270+
monkeypatch.setattr(mcp, "sign_mcp_message", Mock(side_effect=RuntimeError("sign failure")))
271+
272+
transport = mcp.JacsSSETransport("http://127.0.0.1:9000/sse")
273+
message = SimpleNamespace(root={"jsonrpc": "2.0", "id": 7, "method": "ping"})
274+
275+
async def run_send():
276+
async with transport.connect_session():
277+
await write_stream.send(message)
278+
279+
asyncio.run(run_send())
280+
281+
assert sent_payloads == [{"jsonrpc": "2.0", "id": 7, "method": "ping"}]

0 commit comments

Comments
 (0)