///|
/// Recommended handling decision for audited output.
pub(all) enum Decision {
Allow
Review
Block
} derive(Eq, Debug)
///|
/// Returns a stable machine-friendly decision label.
pub fn Decision::label(self : Decision) -> String {
match self {
Allow => "allow"
Review => "review"
Block => "block"
}
}
///|
/// Configures how audit findings become allow, review, or block decisions.
pub struct GatePolicy {
block_on_dangerous : Bool
block_score : Int
review_score : Int
review_finding_count : Int
blocked_kinds : Array[FindingKind]
} derive(Eq, Debug)
///|
/// Conservative policy suitable for public CI output.
pub fn GatePolicy::strict() -> GatePolicy {
{
block_on_dangerous: true,
block_score: 10,
review_score: 1,
review_finding_count: 1,
blocked_kinds: [ClipboardWrite, FileTransfer],
}
}
///|
/// Balanced default that reviews warnings and blocks dangerous output.
pub fn GatePolicy::balanced() -> GatePolicy {
{
block_on_dangerous: true,
block_score: 10,
review_score: 3,
review_finding_count: 3,
blocked_kinds: [ClipboardWrite, FileTransfer],
}
}
///|
/// Policy for trusted internal terminals that still blocks critical payloads.
pub fn GatePolicy::permissive() -> GatePolicy {
{
block_on_dangerous: false,
block_score: 20,
review_score: 6,
review_finding_count: 10,
blocked_kinds: [ClipboardWrite, FileTransfer],
}
}
///|
/// Creates a custom gate policy.
pub fn GatePolicy::new(
block_on_dangerous? : Bool = true,
block_score? : Int = 10,
review_score? : Int = 3,
review_finding_count? : Int = 3,
blocked_kinds? : Array[FindingKind] = [],
) -> GatePolicy {
let normalized_block = if block_score < 1 { 1 } else { block_score }
let normalized_review = if review_score < 0 {
0
} else if review_score > normalized_block {
normalized_block
} else {
review_score
}
{
block_on_dangerous,
block_score: normalized_block,
review_score: normalized_review,
review_finding_count: if review_finding_count < 1 {
1
} else {
review_finding_count
},
blocked_kinds: blocked_kinds.copy(),
}
}
///|
pub fn GatePolicy::blocks_dangerous(self : GatePolicy) -> Bool {
self.block_on_dangerous
}
///|
pub fn GatePolicy::block_threshold(self : GatePolicy) -> Int {
self.block_score
}
///|
pub fn GatePolicy::review_threshold(self : GatePolicy) -> Int {
self.review_score
}
///|
pub fn GatePolicy::finding_threshold(self : GatePolicy) -> Int {
self.review_finding_count
}
///|
pub fn GatePolicy::blocked_kinds(self : GatePolicy) -> Array[FindingKind] {
self.blocked_kinds.copy()
}
///|
fn contains_kind(kinds : Array[FindingKind], kind : FindingKind) -> Bool {
kinds.any(candidate => candidate == kind)
}
///|
/// Applies a configurable automation gate.
pub fn ScanResult::decide_with(
self : ScanResult,
policy : GatePolicy,
) -> Decision {
if self.findings.any(finding => {
contains_kind(policy.blocked_kinds, finding.kind)
}) {
Block
} else if policy.block_on_dangerous && self.dangerous_count() > 0 {
Block
} else if self.risk_score() >= policy.block_score {
Block
} else if self.risk_score() >= policy.review_score ||
self.findings.length() >= policy.review_finding_count {
Review
} else {
Allow
}
}
///|
/// Explains which gate conditions were triggered.
pub fn ScanResult::decision_reasons(
self : ScanResult,
policy : GatePolicy,
) -> Array[String] {
let reasons : Array[String] = []
for finding in self.findings {
if contains_kind(policy.blocked_kinds, finding.kind) {
let reason = "blocked finding kind: \{finding.kind.label()}"
if !reasons.contains(reason) {
reasons.push(reason)
}
}
}
if policy.block_on_dangerous && self.dangerous_count() > 0 {
reasons.push("dangerous findings: \{self.dangerous_count()}")
}
if self.risk_score() >= policy.block_score {
reasons.push(
"risk score \{self.risk_score()} reached block threshold \{policy.block_score}",
)
} else if self.risk_score() >= policy.review_score {
reasons.push(
"risk score \{self.risk_score()} reached review threshold \{policy.review_score}",
)
}
if self.findings.length() >= policy.review_finding_count {
reasons.push(
"finding count \{self.findings.length()} reached review threshold \{policy.review_finding_count}",
)
}
if reasons.length() == 0 {
reasons.push("no gate threshold was reached")
}
reasons
}
///|
/// Count for one finding category.
pub struct KindCount {
kind : FindingKind
count : Int
} derive(Eq, Debug)
///|
pub fn KindCount::kind(self : KindCount) -> FindingKind {
self.kind
}
///|
pub fn KindCount::count(self : KindCount) -> Int {
self.count
}
///|
/// Stable summary of an audit result.
pub struct AuditSummary {
risk_level : String
risk_score : Int
decision : Decision
total_findings : Int
dangerous : Int
warning : Int
info : Int
kind_counts : Array[KindCount]
} derive(Eq, Debug)
///|
pub fn AuditSummary::risk_level(self : AuditSummary) -> String {
self.risk_level
}
///|
pub fn AuditSummary::risk_score(self : AuditSummary) -> Int {
self.risk_score
}
///|
pub fn AuditSummary::decision(self : AuditSummary) -> Decision {
self.decision
}
///|
pub fn AuditSummary::total_findings(self : AuditSummary) -> Int {
self.total_findings
}
///|
pub fn AuditSummary::dangerous_count(self : AuditSummary) -> Int {
self.dangerous
}
///|
pub fn AuditSummary::warning_count(self : AuditSummary) -> Int {
self.warning
}
///|
pub fn AuditSummary::info_count(self : AuditSummary) -> Int {
self.info
}
///|
pub fn AuditSummary::kind_counts(self : AuditSummary) -> Array[KindCount] {
self.kind_counts.copy()
}
///|
/// Controls how much potentially sensitive content is included in reports.
pub struct ReportPolicy {
include_safe_text : Bool
include_sequences : Bool
safe_text_limit : Int
sequence_limit : Int
} derive(Eq, Debug)
///|
/// Includes complete sanitized text and captured control sequences.
pub fn ReportPolicy::full() -> ReportPolicy {
{
include_safe_text: true,
include_sequences: true,
safe_text_limit: 0,
sequence_limit: 0,
}
}
///|
/// Includes content but limits large fields to practical diagnostic sizes.
pub fn ReportPolicy::bounded() -> ReportPolicy {
{
include_safe_text: true,
include_sequences: true,
safe_text_limit: 2048,
sequence_limit: 160,
}
}
///|
/// Keeps sanitized text but removes captured terminal payloads.
pub fn ReportPolicy::redacted() -> ReportPolicy {
{
include_safe_text: true,
include_sequences: false,
safe_text_limit: 2048,
sequence_limit: 0,
}
}
///|
/// Emits only classification and aggregate metadata.
pub fn ReportPolicy::metadata_only() -> ReportPolicy {
{
include_safe_text: false,
include_sequences: false,
safe_text_limit: 0,
sequence_limit: 0,
}
}
///|
pub fn ReportPolicy::new(
include_safe_text? : Bool = true,
include_sequences? : Bool = false,
safe_text_limit? : Int = 2048,
sequence_limit? : Int = 160,
) -> ReportPolicy {
{
include_safe_text,
include_sequences,
safe_text_limit: if safe_text_limit < 0 {
0
} else {
safe_text_limit
},
sequence_limit: if sequence_limit < 0 {
0
} else {
sequence_limit
},
}
}
///|
pub fn ReportPolicy::includes_safe_text(self : ReportPolicy) -> Bool {
self.include_safe_text
}
///|
pub fn ReportPolicy::includes_sequences(self : ReportPolicy) -> Bool {
self.include_sequences
}
///|
pub fn ReportPolicy::safe_text_max_length(self : ReportPolicy) -> Int {
self.safe_text_limit
}
///|
pub fn ReportPolicy::sequence_max_length(self : ReportPolicy) -> Int {
self.sequence_limit
}
///|
/// Returns a typed handling decision.
pub fn ScanResult::recommended_decision(self : ScanResult) -> Decision {
if self.dangerous_count() > 0 {
Block
} else if self.warning_count() > 0 {
Review
} else {
Allow
}
}
///|
/// Counts findings with a specific category.
pub fn ScanResult::count_kind(self : ScanResult, kind : FindingKind) -> Int {
self.findings.fold(init=0, fn(total, finding) {
if finding.kind == kind {
total + 1
} else {
total
}
})
}
///|
fn all_finding_kinds() -> Array[FindingKind] {
[
StyleSequence,
CursorControl,
ScreenControl,
ModeChange,
TerminalQuery,
WindowControl,
TitleChange,
Hyperlink,
ClipboardWrite,
WorkingDirectory,
Notification,
ShellIntegration,
FileTransfer,
PaletteChange,
CharacterSet,
DeviceControl,
ControlCharacter,
TruncatedSequence,
OversizedSequence,
UnknownEscape,
]
}
///|
/// Returns non-empty category counts in stable order.
pub fn ScanResult::kind_counts(self : ScanResult) -> Array[KindCount] {
let counts : Array[KindCount] = []
for kind in all_finding_kinds() {
let count = self.count_kind(kind)
if count > 0 {
counts.push({ kind, count })
}
}
counts
}
///|
/// Creates a compact structured summary.
pub fn ScanResult::summary(self : ScanResult) -> AuditSummary {
{
risk_level: self.risk_level(),
risk_score: self.risk_score(),
decision: self.recommended_decision(),
total_findings: self.findings.length(),
dangerous: self.dangerous_count(),
warning: self.warning_count(),
info: self.info_count(),
kind_counts: self.kind_counts(),
}
}
///|
fn hex_digit(value : Int) -> Char {
if value < 10 {
(value + '0'.to_int()).unsafe_to_char()
} else {
(value - 10 + 'a'.to_int()).unsafe_to_char()
}
}
///|
fn json_escape(input : String) -> String {
let output : Array[Char] = []
for char in input {
let code = char.to_int()
if char == '"' {
output.push('\\')
output.push('"')
} else if char == '\\' {
output.push('\\')
output.push('\\')
} else if char == '\b' {
output.push('\\')
output.push('b')
} else if char == '\u{0c}' {
output.push('\\')
output.push('f')
} else if char == '\n' {
output.push('\\')
output.push('n')
} else if char == '\r' {
output.push('\\')
output.push('r')
} else if char == '\t' {
output.push('\\')
output.push('t')
} else if code < 0x20 {
output.push('\\')
output.push('u')
output.push('0')
output.push('0')
output.push(hex_digit(code / 16 % 16))
output.push(hex_digit(code % 16))
} else {
output.push(char)
}
}
String::from_array(output)
}
///|
fn json_string(value : String) -> String {
"\"\{json_escape(value)}\""
}
///|
fn bounded_text(input : String, limit : Int) -> String {
if limit == 0 {
input
} else {
let chars = input.to_array()
if chars.length() <= limit {
input
} else {
String::from_array(chars[0:limit]) + "..."
}
}
}
///|
fn report_safe_text(result : ScanResult, policy : ReportPolicy) -> String {
if policy.include_safe_text {
json_string(bounded_text(result.text, policy.safe_text_limit))
} else {
"null"
}
}
///|
fn report_sequence(finding : Finding, policy : ReportPolicy) -> String {
if policy.include_sequences {
json_string(bounded_text(finding.sequence_preview(), policy.sequence_limit))
} else {
"null"
}
}
///|
/// Renders one finding as a stable JSON object.
pub fn Finding::to_json(self : Finding) -> String {
self.to_json_with(ReportPolicy::full())
}
///|
/// Renders one finding according to a report redaction policy.
pub fn Finding::to_json_with(self : Finding, policy : ReportPolicy) -> String {
"{" +
"\"kind\":" +
json_string(self.kind.label()) +
",\"severity\":" +
json_string(self.severity.label()) +
",\"offset\":" +
self.offset.to_string() +
",\"sequence\":" +
report_sequence(self, policy) +
",\"message\":" +
json_string(self.message) +
"}"
}
///|
fn kind_counts_json(counts : Array[KindCount]) -> String {
let mut output = "{"
for index, count in counts {
if index > 0 {
output = output + ","
}
output = output +
"\{json_string(count.kind.label())}:\{count.count.to_string()}"
}
output + "}"
}
///|
fn findings_json(findings : Array[Finding], policy : ReportPolicy) -> String {
let mut output = "["
for index, finding in findings {
if index > 0 {
output = output + ","
}
output = output + finding.to_json_with(policy)
}
output + "]"
}
///|
/// Renders a machine-readable JSON audit report.
pub fn ScanResult::json_report(self : ScanResult) -> String {
self.json_report_with(ReportPolicy::full())
}
///|
/// Renders a JSON report with configurable content redaction and limits.
pub fn ScanResult::json_report_with(
self : ScanResult,
policy : ReportPolicy,
) -> String {
let summary = self.summary()
"{" +
"\"schema\":\"moonansi-guard/v1\"" +
",\"risk_level\":" +
json_string(summary.risk_level) +
",\"risk_score\":" +
summary.risk_score.to_string() +
",\"decision\":" +
json_string(summary.decision.label()) +
",\"removed_count\":" +
self.removed_count.to_string() +
",\"counts\":{\"dangerous\":" +
summary.dangerous.to_string() +
",\"warning\":" +
summary.warning.to_string() +
",\"info\":" +
summary.info.to_string() +
"}" +
",\"kind_counts\":" +
kind_counts_json(summary.kind_counts) +
",\"safe_text\":" +
report_safe_text(self, policy) +
",\"findings\":" +
findings_json(self.findings, policy) +
"}"
}
///|
/// Renders a human-readable report without exposing captured sequence payloads.
pub fn ScanResult::redacted_audit_report(self : ScanResult) -> String {
let mut report = "MoonAnsi Guard redacted audit report\n"
report = report + "risk: \{self.risk_level()} (score=\{self.risk_score()})\n"
report = report + "action: \{self.recommended_action()}\n"
report = report +
"findings: dangerous=\{self.dangerous_count()}, warning=\{self.warning_count()}, info=\{self.info_count()}\n"
report = report + "safe text: \{bounded_text(self.text, 2048)}\n"
if self.findings.length() == 0 {
report = report + "details: no terminal control sequences found\n"
} else {
report = report + "details:\n"
for finding in self.findings {
report = report +
"- \{finding.kind.label()} [\{finding.severity.label()}] offset \{finding.offset}: \{finding.message}; sequence=\n"
}
}
report
}
///|
/// Aggregates results from multiple chunks or independent scan operations.
pub struct AuditSession {
mut segment_count : Int
mut safe_text : String
findings : Array[Finding]
}
///|
pub fn AuditSession::new() -> AuditSession {
{ segment_count: 0, safe_text: "", findings: [] }
}
///|
/// Adds one scan result while preserving its finding offsets.
pub fn AuditSession::record(self : AuditSession, result : ScanResult) -> Unit {
self.segment_count = self.segment_count + 1
self.safe_text = self.safe_text + result.text
for finding in result.findings {
self.findings.push(finding)
}
}
///|
pub fn AuditSession::segment_count(self : AuditSession) -> Int {
self.segment_count
}
///|
pub fn AuditSession::finding_count(self : AuditSession) -> Int {
self.findings.length()
}
///|
/// Returns an immutable snapshot compatible with the normal reporting API.
pub fn AuditSession::result(self : AuditSession) -> ScanResult {
{
text: self.safe_text,
findings: self.findings.copy(),
removed_count: self.findings.length(),
}
}
///|
pub fn AuditSession::reset(self : AuditSession) -> Unit {
self.segment_count = 0
self.safe_text = ""
self.findings.clear()
}