///|
pub struct PolicyDiagnostic {
plugin : String
rule : String
file : String?
line : Int32?
severity : String
message : String
}
///|
pub impl @json.FromJson for PolicyDiagnostic with fn from_json(value, path) {
guard value
is Object(
{
"plugin": String(plugin),
"rule": String(rule),
"file": file,
"line": line,
"severity": String(severity),
"message": String(message),
..
}
) else {
raise @json.JsonDecodeError::JsonDecodeError(
(path, "invalid policy diagnostic"),
)
}
let file = match file {
Null => None
String(value) => Some(value)
_ =>
raise @json.JsonDecodeError::JsonDecodeError(
(path, "invalid diagnostic file"),
)
}
let line = match line {
Null => None
Number(value, ..) if value >= 1.0 &&
value <= 2147483647.0 &&
value.to_int().to_double() == value => Some(value.to_int())
_ =>
raise @json.JsonDecodeError::JsonDecodeError(
(path, "invalid diagnostic line"),
)
}
{ plugin, rule, file, line, severity, message, }
}
///|
pub impl @json.ToJson for PolicyDiagnostic with fn to_json(self) {
let file : Json = match self.file {
Some(value) => Json::string(value)
None => null
}
let line : Json = match self.line {
Some(value) => Json::number(value.to_double())
None => null
}
{
"plugin": self.plugin,
"rule": self.rule,
"file": file,
"line": line,
"severity": self.severity,
"message": self.message,
}
}
///|
pub(all) enum EngineDecision {
Accept
Warning(Array[PolicyDiagnostic])
Reject(Array[PolicyDiagnostic])
EngineError(String)
}
///|
pub struct RequiredPolicy {
name : String
version : String
module_digest : String
component : String
manifest : String
granted_capabilities : Array[String]
} derive(@json.FromJson, @json.ToJson)
///|
pub struct PolicyLimits {
fuel : Int32
timeout_ms : Int32
max_memory_bytes : Int32
max_input_bytes : Int32
max_output_bytes : Int32
max_log_bytes : Int32
} derive(@json.FromJson, @json.ToJson)
///|
pub struct ConfiguredRule {
id : String
literal : String
} derive(@json.FromJson, @json.ToJson)
///|
pub struct PolicyContext {
schema : Int32
host : String
required : Array[RequiredPolicy]
allowed_capabilities : Array[String]
limits : PolicyLimits
custom_rules : Array[ConfiguredRule]
} derive(@json.FromJson, @json.ToJson)
///|
pub extend PolicyContext with @json.FromJson::{from_json}
///|
pub extend PolicyContext with @json.ToJson::{to_json}
///|
pub extend PolicyDiagnostic with @json.FromJson::{from_json}
///|
pub extend PolicyDiagnostic with @json.ToJson::{to_json}
///|
pub extend RequiredPolicy with @json.FromJson::{from_json}
///|
pub extend RequiredPolicy with @json.ToJson::{to_json}
///|
pub extend PolicyLimits with @json.FromJson::{from_json}
///|
pub extend PolicyLimits with @json.ToJson::{to_json}
///|
pub extend ConfiguredRule with @json.FromJson::{from_json}
///|
pub extend ConfiguredRule with @json.ToJson::{to_json}
///|
pub fn parse_policy_context(text : String) -> PolicyContext raise {
let context : PolicyContext = try {
let value = @json.parse(text)
// v1 used one repository-wide grant list. Upgrade only v1, never infer
// v2 grants from an untrusted plugin manifest.
if value is Object(fields) {
if fields.get("schema") == Some(Json::number(1.0)) {
if fields.get("required") is Some(Array(plugins)) {
for plugin in plugins {
if plugin is Object(pin) && !pin.contains("granted_capabilities") {
pin["granted_capabilities"] = match
fields.get("allowed_capabilities") {
Some(grants) => grants
None => Json::array([])
}
}
}
}
}
if fields.get("limits") is Some(Object(limits)) &&
!limits.contains("max_log_bytes") {
limits["max_log_bytes"] = Json::number(65536.0)
}
}
@json.from_json(value)
} catch {
_ =>
raise VcsError::PolicyExecutionFailed(
"invalid required-policy configuration",
)
}
if (context.schema != 1 && context.schema != 2) ||
context.limits.fuel <= 0 ||
context.limits.timeout_ms <= 0 ||
context.limits.timeout_ms > 60000 ||
context.limits.max_input_bytes <= 0 ||
context.limits.max_input_bytes > 1048576 ||
context.limits.max_output_bytes <= 0 ||
context.limits.max_output_bytes > 1048576 ||
context.limits.max_log_bytes <= 0 ||
context.limits.max_log_bytes > 1048576 ||
context.limits.max_memory_bytes < 65536 ||
context.limits.max_memory_bytes > 268435456 {
raise VcsError::PolicyExecutionFailed(
"unsupported policy configuration or invalid budgets",
)
}
let names : Map[String, Bool] = Map([])
for plugin in context.required {
if plugin.name.is_empty() ||
plugin.version.is_empty() ||
names.contains(plugin.name) ||
!plugin.module_digest.has_prefix("sha256:") ||
!plugin.component.has_prefix("/") ||
!plugin.manifest.has_prefix("/") ||
!context.host.has_prefix("/") {
raise VcsError::PolicyExecutionFailed(
"invalid required-policy identity or runtime paths",
)
}
try ObjectId::parse(plugin.module_digest[7:].to_owned()) |> ignore catch {
_ =>
raise VcsError::PolicyExecutionFailed("invalid required-policy digest")
}
names[plugin.name] = true
}
context
}
///|
fn candidate_transport(
candidate : CommitCandidate,
context : PolicyContext,
plugin : RequiredPolicy,
hook : String,
) -> String raise {
if candidate.parents.length() > 1 {
raise VcsError::PolicyExecutionFailed(
"WIT v1 supports at most one candidate parent",
)
}
let files : Array[Json] = []
let mut input_bytes = @utf8.encode(candidate.message).length()
for file in candidate.staged_files {
input_bytes = input_bytes +
file.content.length() +
@utf8.encode(file.path).length()
if input_bytes > context.limits.max_input_bytes {
raise VcsError::PolicyExecutionFailed(
"candidate exceeds policy input limit",
)
}
files.push({
"path": file.path,
"content": file.content.to_array().map(byte => byte.to_int()),
})
}
let parent : Json = match candidate.parents {
[id] => Json::string(id.hex)
_ => null
}
let value : Json = {
"schema": 1,
"expected_plugin": {
"name": plugin.name,
"version": plugin.version,
"module_digest": plugin.module_digest,
},
"policy": {
"allowed_capabilities": plugin.granted_capabilities.filter(capability => {
context.allowed_capabilities.contains(capability)
}),
"limits": context.limits,
"custom_rules": context.custom_rules,
},
"request": {
"hook": hook,
"policy_version": "1",
"commit_message": candidate.message,
"parent_id": parent,
"tree_id": candidate.tree_id.hex,
"files": files,
},
}
value.stringify()
}
///|
priv struct HostPolicyResult {
status : String
plugin : String
plugin_version : String
diagnostics : Array[PolicyDiagnostic]
} derive(@json.FromJson)
///|
async fn call_policy_host(
host : String,
plugin : RequiredPolicy,
input : String,
limits : PolicyLimits,
) -> (Int32, Bytes) {
@async.with_task_group() <| group => {
let (stdin, writer) = @process.write_to_process()
group.spawn_bg(() => {
defer writer.close()
writer.write(@utf8.encode(input))
})
let (stdout_reader, stdout) = @process.read_from_process()
let (stderr_reader, stderr) = @process.read_from_process()
let process = group.spawn(() => {
@process.run(
host,
[
"--request-stdin",
"--component",
plugin.component,
"--manifest",
plugin.manifest,
],
stdin~,
stdout~,
stderr~,
inherit_env=false,
cancel_handler=@process.hard_cancel(),
)
})
let output = group.spawn(() => {
defer stdout_reader.close()
read_policy_pipe(stdout_reader, limits.max_output_bytes + 4096)
})
let logs = group.spawn(() => {
defer stderr_reader.close()
read_policy_pipe(stderr_reader, limits.max_log_bytes)
})
let bytes = output.wait()
logs.wait() |> ignore
(process.wait(), bytes)
}
}
///|
async fn read_policy_pipe(reader : &@io.Reader, limit : Int32) -> Bytes {
let bytes : Array[Byte] = []
while reader.read_some(max_len=4096) is Some(chunk) {
if bytes.length() + chunk.length() > limit {
raise VcsError::PolicyExecutionFailed(
"policy process output/log limit exceeded",
)
}
for byte in chunk {
bytes.push(byte)
}
}
Bytes::from_array(bytes)
}
///|
pub async fn validate_policies(
candidate : CommitCandidate,
context : PolicyContext,
hook~ : String = "on-commit",
) -> EngineDecision {
if hook != "on-commit" && hook != "on-receive" {
return EngineError("unsupported policy lifecycle hook")
}
let diagnostics : Array[PolicyDiagnostic] = []
let mut rejected = false
let mut warned = false
for plugin in context.required {
let (code, output) = @async.with_timeout(
context.limits.timeout_ms + 15000,
() => {
call_policy_host(
context.host,
plugin,
candidate_transport(candidate, context, plugin, hook),
context.limits,
)
},
) catch {
_ =>
return EngineError(
"required policy " + plugin.name + " could not execute",
)
}
if code != 0 && code != 2 {
return EngineError(
"required policy " + plugin.name + " failed (host exit \{code})",
)
}
if output.length() > context.limits.max_output_bytes + 1 {
return EngineError("required policy result exceeds transport limit")
}
let result : HostPolicyResult = @json.from_json(
@json.parse(@utf8.decode(output)),
) catch {
_ => return EngineError("required policy returned an invalid result")
}
if result.plugin != plugin.name ||
result.plugin_version != plugin.version ||
(code == 0 && result.status != "pass" && result.status != "warning") ||
(code == 2 && result.status != "reject") {
return EngineError("required policy result identity or status mismatch")
}
for diagnostic in result.diagnostics {
if diagnostic.plugin != plugin.name {
return EngineError("required policy diagnostic identity mismatch")
}
}
if result.status == "reject" {
rejected = true
if result.diagnostics.is_empty() {
return EngineError("required policy rejected without diagnostics")
}
}
if result.status == "warning" {
warned = true
}
for diagnostic in result.diagnostics {
diagnostics.push(diagnostic)
}
}
if rejected {
Reject(diagnostics)
} else if warned {
Warning(diagnostics)
} else {
Accept
}
}
///|
fn format_policy_diagnostics(diagnostics : Array[PolicyDiagnostic]) -> String {
let lines : Array[String] = []
for item in diagnostics {
lines.push("Policy: " + safe_policy_text(item.plugin))
let path = match item.file {
Some(path) => path
None => ""
}
lines.push(
"File: " +
safe_policy_text(path) +
" | Rule: " +
safe_policy_text(item.rule) +
" | Severity: " +
safe_policy_text(item.severity),
)
lines.push("Reason: " + safe_policy_text(item.message))
}
lines.join("\n")
}
///|
fn safe_policy_text(value : String) -> String {
let quoted = Json::string(value).stringify()
quoted[1:quoted.length() - 1].to_owned()
}