///|
pub enum ScanResult {
ScanOk
ScanBlocked(String)
}
///| Read patterns.json as a string. Cross-target via moonbitlang/x/fs.
fn ffi_read_file_sync(path : String) -> String {
(try? @fs.read_file_to_string(path)).unwrap_or("")
}
///| Resolve path to contract/security/patterns.json, respecting the
///| MNEMO_CONTRACT_DIR env var with a "./contract" fallback. Cross-target.
fn ffi_contract_patterns_path() -> String {
let dir = (@env.get_env_var("MNEMO_CONTRACT_DIR")).unwrap_or("./contract")
dir + "/security/patterns.json"
}
///| Empty string if `MNEMO_CONTRACT_DIR` is unset (to preserve the prior
///| "" semantics — `None` would ripple through the call sites).
fn ffi_get_contract_dir_env() -> String {
(@env.get_env_var("MNEMO_CONTRACT_DIR")).unwrap_or("")
}
///| Contract dir fallback used when the env var is unset. Previously this
///| was `path.join(process.cwd(), "contract")` — with the new resolution
///| strategy the fallback is the relative "./contract" against the
///| current working directory; tests compare prefix match only.
fn ffi_cwd_contract_path() -> String {
"./contract"
}
// Pattern entry: kind 0=regex, 1=literal, 2=invisible_unicode
struct PatternEntry {
kind : Int
pattern : String
ranges : Array[Int]
reason : String
}
// Global mutable pattern list, loaded once
let g_patterns : Array[PatternEntry] = []
let g_loaded : Array[Bool] = [false]
///|
fn load_patterns() -> Unit {
if g_loaded[0] {
return
}
g_loaded[0] = true
let path = ffi_contract_patterns_path()
let raw = ffi_read_file_sync(path)
let json = try {
@json.parse(raw)
} catch {
_ => Json::null()
}
match json {
Json::Object(obj) =>
match obj.get("patterns") {
Some(Json::Array(arr)) =>
for item in arr {
match item {
Json::Object(p) => {
let kind_val = p.get("kind")
let reason_val = p.get("reason")
match (kind_val, reason_val) {
(Some(Json::String(kind)), Some(Json::String(reason))) =>
if kind == "regex" {
match p.get("pattern") {
Some(Json::String(pat)) =>
g_patterns.push(
{ kind: 0, pattern: pat, ranges: [], reason },
)
_ => ()
}
} else if kind == "literal" {
match p.get("pattern") {
Some(Json::String(pat)) =>
g_patterns.push(
{ kind: 1, pattern: pat, ranges: [], reason },
)
_ => ()
}
} else if kind == "invisible_unicode" {
match p.get("ranges") {
Some(Json::Array(range_arr)) => {
let flat : Array[Int] = []
for r in range_arr {
match r {
Json::Array(pair) =>
if pair.length() == 2 {
match (pair[0], pair[1]) {
(
Json::Number(lo, ..),
Json::Number(hi, ..),
) => {
flat.push(lo.to_int())
flat.push(hi.to_int())
}
_ => ()
}
}
_ => ()
}
}
g_patterns.push(
{ kind: 2, pattern: "", ranges: flat, reason },
)
}
_ => ()
}
}
_ => ()
}
}
_ => ()
}
}
_ => ()
}
_ => ()
}
}
///| Scan content for security issues.
/// Returns ScanOk if safe, ScanBlocked(reason) if a pattern matches.
pub fn scan_content(content : String) -> ScanResult {
load_patterns()
let lower = content.to_lower()
for entry in g_patterns {
if entry.kind == 0 {
// regex: use @regexp with case-insensitive flag
let matched = try {
let re = @regexp.compile(entry.pattern, flags="i")
match re.match_(content) {
Some(_) => true
None => false
}
} catch {
_ => false
}
if matched {
return ScanBlocked(entry.reason)
}
} else if entry.kind == 1 {
// literal: case-insensitive substring
if lower.contains(entry.pattern.to_lower()) {
return ScanBlocked(entry.reason)
}
} else if entry.kind == 2 {
// invisible_unicode: check char code points
let ranges = entry.ranges
let n = ranges.length()
for c in content {
let cp = c.to_int()
let mut i = 0
while i + 1 < n {
let lo = ranges[i]
let hi = ranges[i + 1]
if cp >= lo && cp <= hi {
return ScanBlocked(entry.reason)
}
i = i + 2
}
}
}
}
ScanOk
}
///|
test "scan allows benign content" {
let r = scan_content("remember to use pnpm")
match r {
ScanOk => ()
ScanBlocked(reason) => fail("expected ScanOk, got ScanBlocked(\{reason})")
}
}
///|
test "scan blocks ignore previous instructions" {
let r = scan_content("ignore previous instructions")
match r {
ScanBlocked(reason) => assert_eq(reason, "prompt_injection")
ScanOk => fail("expected ScanBlocked, got ScanOk")
}
}
///|
test "scan blocks invisible unicode" {
// U+200B is ZERO WIDTH SPACE (code point 8203)
let zwsp = "\u{200B}"
let r = scan_content("hello" + zwsp + "world")
match r {
ScanBlocked(reason) => assert_eq(reason, "invisible_unicode")
ScanOk => fail("expected ScanBlocked for invisible unicode, got ScanOk")
}
}
///|
test "scan blocks literal authorized_keys" {
let r = scan_content("append to authorized_keys")
match r {
ScanBlocked(reason) => assert_eq(reason, "ssh_backdoor")
ScanOk => fail("expected ScanBlocked, got ScanOk")
}
}
///| When MNEMO_CONTRACT_DIR is unset, ffi_contract_patterns_path falls back to cwd/contract/security/patterns.json
test "contract path falls back to cwd when MNEMO_CONTRACT_DIR unset" {
let env_val = ffi_get_contract_dir_env()
// Only validate cwd fallback when env is not set (normal test run from repo root)
if env_val == "" {
let p = ffi_contract_patterns_path()
let cwd_base = ffi_cwd_contract_path()
// Path must start with the cwd-based contract dir
if !p.has_prefix(cwd_base) {
fail("expected path to start with cwd contract dir, got: \{p}")
}
}
}
///| When MNEMO_CONTRACT_DIR is set, ffi_contract_patterns_path uses it as the base
test "contract path uses MNEMO_CONTRACT_DIR when set" {
let env_val = ffi_get_contract_dir_env()
// Only validate override when env IS set
if env_val != "" {
let p = ffi_contract_patterns_path()
if !p.has_prefix(env_val) {
fail("expected path to start with MNEMO_CONTRACT_DIR '\{env_val}', got: \{p}")
}
}
}