///|
/// Policy model.
///
/// A policy is a small, declarative document with five independent controls:
///
/// 1. `rules` decide per tool call (allow / deny / approval), first match wins.
/// 2. `egress` blocks outbound tools whose arguments carry values that earlier
/// tool results marked sensitive.
/// 3. `sensitive` declares which tool results should be fingerprinted.
/// 4. `rate` caps how many tool calls a session may attempt per window.
/// 5. `advertise` decides whether tools the policy denies are hidden from
/// `tools/list` (so the agent never plans around them).
///
/// Rule order is meaningful on purpose: a firewall-style first-match model is
/// easier to reason about than "most specific wins", and the linter in
/// `docs/POLICY.md` shows how to order rules so broad grants do not shadow
/// narrow denials.
pub struct ArgCheck {
arg : String
globs : Array[String]
prefixes : Array[String]
negate : Bool
normalize_path : Bool
}
///|
pub struct Rule {
id : String
tool : String
decision : String
reason : String
checks : Array[ArgCheck]
approval_ttl_ms : Int32
}
///|
pub struct SensitiveRule {
id : String
tool : String
checks : Array[ArgCheck]
}
///|
/// A destination that is allowed to receive a protected value. Reveal rules are
/// intentionally separate from `rules`: allowing a tool to run is not the same
/// as allowing it to see a secret.
pub struct RevealRule {
id : String
tool : String
checks : Array[ArgCheck]
reason : String
}
///|
pub struct RateLimit {
window_ms : Int32
max_calls : Int32
}
///|
/// Session-wide quotas. Zero disables a quota; a missing budget should not
/// lock a session, so zero never means "allow nothing".
pub struct Budget {
max_calls : Int32
max_arg_bytes : Int32
max_single_bytes : Int32
max_reveals : Int32
}
///|
pub struct Policy {
name : String
digest : String
default_decision : String
rules : Array[Rule]
sensitive : Array[SensitiveRule]
reveal : Array[RevealRule]
mask_secrets : Bool
egress_tools : Array[String]
on_taint : String
rate : RateLimit
budget : Budget
approval_ttl_ms : Int32
min_secret_len : Int32
hide_denied : Bool
}
///|
pub suberror PolicyError {
PolicyError(String)
}
///|
fn[T] policy_fail(message : String) -> T raise PolicyError {
raise PolicyError(message)
}
///|
fn policy_text(
fields : Map[String, Json],
key : String,
path : String,
) -> String raise PolicyError {
match fields.get(key) {
Some(String(s)) => s
Some(_) => policy_fail("\{path}.\{key}: expected string")
None => policy_fail("\{path}.\{key}: required")
}
}
///|
fn policy_optional_text(
fields : Map[String, Json],
key : String,
fallback : String,
) -> String {
match fields.get(key) {
Some(String(s)) => s
_ => fallback
}
}
///|
fn policy_int(
fields : Map[String, Json],
key : String,
path : String,
fallback? : Int32,
min? : Int32,
max? : Int32,
) -> Int32 raise PolicyError {
let value = match fields.get(key) {
Some(Number(n, ..)) =>
if n != n.to_int().to_double() {
policy_fail("\{path}.\{key}: expected integer")
} else {
n.to_int()
}
Some(_) => policy_fail("\{path}.\{key}: expected integer")
None =>
match fallback {
Some(v) => v
None => policy_fail("\{path}.\{key}: required")
}
}
match min {
Some(low) =>
if value < low {
policy_fail("\{path}.\{key}: must be >= \{low}")
}
None => ()
}
match max {
Some(high) =>
if value > high {
policy_fail("\{path}.\{key}: must be <= \{high}")
}
None => ()
}
value
}
///|
fn policy_bool(
fields : Map[String, Json],
key : String,
fallback : Bool,
) -> Bool {
match fields.get(key) {
Some(True) => true
Some(False) => false
_ => fallback
}
}
///|
fn policy_array(fields : Map[String, Json], key : String) -> Array[Json] {
match fields.get(key) {
Some(Array(items)) => items
_ => []
}
}
///|
fn parse_checks(
value : Json,
path : String,
) -> Array[ArgCheck] raise PolicyError {
let items = match value {
Array(items) => items
_ => policy_fail("\{path}: expected array")
}
let checks : Array[ArgCheck] = []
for item in items {
match item {
Object(fields) => {
let arg = policy_text(fields, "arg", path)
if arg.is_empty() || arg.length() > 128 {
policy_fail("\{path}.arg: expected 1..128 characters")
}
let globs = string_array(
match fields.get("glob") {
Some(v) => v
None => Json::array([])
},
)
let prefixes = string_array(
match fields.get("prefix") {
Some(v) => v
None => Json::array([])
},
)
if globs.is_empty() && prefixes.is_empty() {
policy_fail("\{path}: each check needs a glob or prefix list")
}
checks.push({
arg,
globs,
prefixes,
negate: policy_bool(fields, "negate", false),
normalize_path: policy_optional_text(fields, "normalize", "") ==
"path",
})
}
_ => policy_fail("\{path}: expected object")
}
}
checks
}
///|
fn parse_rule(value : Json, index : Int32) -> Rule raise PolicyError {
let path = "rules[\{index}]"
match value {
Object(fields) => {
let id = policy_text(fields, "id", path)
if id.is_empty() || id.length() > 64 {
policy_fail("\{path}.id: expected 1..64 characters")
}
let tool = policy_text(fields, "tool", path)
if tool.is_empty() || tool.length() > 200 {
policy_fail("\{path}.tool: expected 1..200 characters")
}
let decision = policy_text(fields, "decision", path)
if decision != "allow" && decision != "deny" && decision != "approval" {
policy_fail("\{path}.decision: expected allow, deny or approval")
}
let checks = match fields.get("when") {
Some(v) => parse_checks(v, "\{path}.when")
None => []
}
{
id,
tool,
decision,
reason: policy_optional_text(fields, "reason", ""),
checks,
approval_ttl_ms: policy_int(
fields,
"approval_ttl_ms",
path,
fallback=45000,
min=1000,
max=86400000,
),
}
}
_ => policy_fail("\{path}: expected object")
}
}
///|
fn parse_sensitive(
value : Json,
index : Int32,
) -> SensitiveRule raise PolicyError {
let path = "sensitive[\{index}]"
match value {
Object(fields) => {
let tool = policy_text(fields, "tool", path)
let checks = match fields.get("when") {
Some(v) => parse_checks(v, "\{path}.when")
None => []
}
{
id: policy_optional_text(fields, "id", "sensitive-\{index}"),
tool,
checks,
}
}
_ => policy_fail("\{path}: expected object")
}
}
///|
/// Parse and validate a policy document. Every rejection carries the JSON
/// path of the offending field; no input value is echoed back, because policy
/// files may contain internal tool names that should not end up in logs.
pub fn parse_policy(text : String) -> Policy raise PolicyError {
let root = match json_parse_opt(text) {
Some(value) => value
None => policy_fail("policy: invalid JSON")
}
if json_depth(root) > 8 {
policy_fail("policy: nesting deeper than 8 levels")
}
let fields = match root {
Object(fields) => fields
_ => policy_fail("policy: expected object")
}
let version = policy_int(
fields,
"version",
"policy",
fallback=1,
min=1,
max=1,
)
let _ = version
let default_decision = policy_optional_text(
fields, "default_decision", "deny",
)
if default_decision != "allow" && default_decision != "deny" {
policy_fail("policy.default_decision: expected allow or deny")
}
let rule_items = policy_array(fields, "rules")
if rule_items.length() > 128 {
policy_fail("policy.rules: at most 128 rules")
}
let rules : Array[Rule] = []
let seen : Map[String, Bool] = Map([])
for i, item in rule_items {
let rule = parse_rule(item, i)
if seen.contains(rule.id) {
policy_fail("policy.rules[\{i}].id: duplicate rule id")
}
seen.set(rule.id, true)
rules.push(rule)
}
let sensitive_items = policy_array(fields, "sensitive")
if sensitive_items.length() > 64 {
policy_fail("policy.sensitive: at most 64 entries")
}
let sensitive : Array[SensitiveRule] = []
for i, item in sensitive_items {
sensitive.push(parse_sensitive(item, i))
}
let reveal_items = policy_array(fields, "reveal")
if reveal_items.length() > 64 {
policy_fail("policy.reveal: at most 64 entries")
}
let reveal : Array[RevealRule] = []
for i, item in reveal_items {
match item {
Object(rule) => {
let path = "policy.reveal[\{i}]"
reveal.push({
id: policy_optional_text(rule, "id", "reveal-\{i}"),
tool: policy_text(rule, "tool", path),
checks: match rule.get("when") {
Some(v) => parse_checks(v, "\{path}.when")
None => []
},
reason: policy_optional_text(rule, "reason", ""),
})
}
_ => policy_fail("policy.reveal[\{i}]: expected object")
}
}
let egress_tools : Array[String] = []
let mut on_taint = "deny"
match fields.get("egress") {
Some(Object(egress)) => {
for
tool in string_array(
match egress.get("tools") {
Some(v) => v
None => Json::array([])
},
) {
if tool.is_empty() || tool.length() > 200 {
policy_fail("policy.egress.tools: expected 1..200 characters")
}
egress_tools.push(tool)
}
on_taint = policy_optional_text(egress, "on_taint", "deny")
if on_taint != "deny" && on_taint != "approval" {
policy_fail("policy.egress.on_taint: expected deny or approval")
}
}
Some(_) => policy_fail("policy.egress: expected object")
None => ()
}
let mut rate_window = 60000
let mut rate_max = 60
match fields.get("rate") {
Some(Object(rate)) => {
rate_window = policy_int(
rate,
"window_ms",
"policy.rate",
fallback=60000,
min=1000,
max=86400000,
)
rate_max = policy_int(
rate,
"max_calls",
"policy.rate",
fallback=60,
min=1,
max=100000,
)
}
Some(_) => policy_fail("policy.rate: expected object")
None => ()
}
let mut budget_calls : Int32 = 0
let mut budget_bytes : Int32 = 0
let mut budget_single : Int32 = 0
let mut budget_reveals : Int32 = 0
match fields.get("budget") {
Some(Object(budget)) => {
budget_calls = policy_int(
budget,
"max_calls",
"policy.budget",
fallback=0,
min=0,
max=10000000,
)
budget_bytes = policy_int(
budget,
"max_arg_bytes",
"policy.budget",
fallback=0,
min=0,
max=1073741824,
)
budget_single = policy_int(
budget,
"max_single_bytes",
"policy.budget",
fallback=0,
min=0,
max=1073741824,
)
budget_reveals = policy_int(
budget,
"max_reveals",
"policy.budget",
fallback=0,
min=0,
max=1000000,
)
}
Some(_) => policy_fail("policy.budget: expected object")
None => ()
}
{
name: policy_optional_text(fields, "name", "unnamed"),
digest: sha256_hex(json_canonical(root)),
default_decision,
rules,
sensitive,
reveal,
mask_secrets: policy_bool(fields, "mask_secrets", true),
egress_tools,
on_taint,
rate: { window_ms: rate_window, max_calls: rate_max, },
budget: {
max_calls: budget_calls,
max_arg_bytes: budget_bytes,
max_single_bytes: budget_single,
max_reveals: budget_reveals,
},
approval_ttl_ms: policy_int(
fields,
"approval_ttl_ms",
"policy",
fallback=45000,
min=1000,
max=86400000,
),
min_secret_len: policy_int(
fields,
"min_secret_len",
"policy",
fallback=12,
min=6,
max=128,
),
hide_denied: policy_bool(fields, "hide_denied", true),
}
}
///|
/// Evaluate one `when` clause against the call arguments.
fn check_matches(check : ArgCheck, args : Json) -> Bool {
let value = match json_get(args, check.arg) {
Some(String(s)) => s
Some(other) => json_render(other)
None => ""
}
let value = if check.normalize_path { normalize_path(value) } else { value }
let mut hit = false
for glob in check.globs {
if glob_match(glob, value) {
hit = true
}
}
for prefix in check.prefixes {
if value.has_prefix(prefix) {
hit = true
}
}
if check.negate {
!hit
} else {
hit
}
}
///|
pub fn checks_match(checks : Array[ArgCheck], args : Json) -> Bool {
for check in checks {
if !check_matches(check, args) {
return false
}
}
true
}
///|
pub struct RuleHit {
id : String
decision : String
reason : String
ttl_ms : Int32
}
///|
/// First matching rule wins. Returns `None` when no rule matches, leaving the
/// decision to `default_decision`.
pub fn match_rule(policy : Policy, tool : String, args : Json) -> RuleHit? {
for rule in policy.rules {
if glob_match(rule.tool, tool) && checks_match(rule.checks, args) {
return Some({
id: rule.id,
decision: rule.decision,
reason: rule.reason,
ttl_ms: rule.approval_ttl_ms,
})
}
}
None
}
///|
/// Whether a tool result must be fingerprinted. Sensitive sources are matched
/// against the same arguments that were sent to the tool.
pub fn is_sensitive(policy : Policy, tool : String, args : Json) -> Bool {
for rule in policy.sensitive {
if glob_match(rule.tool, tool) && checks_match(rule.checks, args) {
return true
}
}
false
}
///|
/// Whether a tool can carry data outside the trust boundary.
pub fn is_egress(policy : Policy, tool : String) -> Bool {
any_glob(policy.egress_tools, tool)
}
///|
/// Whether a destination may receive the real value behind a placeholder.
/// Absence of a matching rule means the placeholder stays unresolved and the
/// call is refused by the engine.
pub fn match_reveal(policy : Policy, tool : String, args : Json) -> RevealRule? {
for rule in policy.reveal {
if glob_match(rule.tool, tool) && checks_match(rule.checks, args) {
return Some(rule)
}
}
None
}