Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ jobs:
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- run: nix develop .#fstar --command just fstar-extract
- uses: actions/upload-artifact@v4
with:
name: fstar-extracted
path: fstar/out/*.ml
if-no-files-found: warn

futhark-build:
name: Futhark compile
Expand Down
19 changes: 18 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,17 @@
else
pkgs.ocamlPackages;

# ── Futhark compiled kernels ──────────────────────────
# Each kernel is compiled to a separate shared library to avoid
# symbol collisions (all export futhark_context_new etc.)
# Source is in futhark/out/ (gitignored, built by CI futhark-build job).
# For local dev, the OCaml FFI bridge falls back to pure-OCaml stubs.
# Build locally: just futhark-build && just futhark-kernels
sharedLibExt = if pkgs.stdenv.hostPlatform.isDarwin then "dylib" else "so";

# ── OCaml package dependencies ─────────────────────────
ocamlDeps = with ocamlPkgs; [
findlib yojson cmdliner sha uuidm logs fmt
findlib yojson cmdliner sha uuidm logs fmt ctypes ctypes-foreign
];
ocamlTestDeps = with ocamlPkgs; [ alcotest ];

Expand Down Expand Up @@ -222,6 +230,15 @@
] ++ securityTools;

shellHook = ''
# Futhark kernels: set path if locally built
if [ -d "futhark/lib" ]; then
export FUTHARK_KERNEL_PATH="$PWD/futhark/lib"
'' + (if pkgs.stdenv.hostPlatform.isDarwin then ''
export DYLD_LIBRARY_PATH="$PWD/futhark/lib''${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"
'' else ''
export LD_LIBRARY_PATH="$PWD/futhark/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
'') + ''
fi
echo "hexstrike-dev shell ready"
echo " dhall : $(dhall version 2>/dev/null || echo 'not found')"
echo " futhark : $(futhark --version 2>/dev/null || echo 'not found')"
Expand Down
9 changes: 9 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ futhark-build:
futhark c --library -o futhark/out/network_graph futhark/network_graph.fut
@echo ":: futhark OK"

# Compile Futhark kernels to shared libraries for OCaml FFI
futhark-kernels:
@echo ":: futhark kernels"
mkdir -p futhark/lib
cc -shared -fPIC -O2 -o futhark/lib/libscan_analysis.{{if os() == "macos" { "dylib" } else { "so" }}} futhark/out/scan_analysis.c -lm
cc -shared -fPIC -O2 -o futhark/lib/libpattern_match.{{if os() == "macos" { "dylib" } else { "so" }}} futhark/out/pattern_match.c -lm
cc -shared -fPIC -O2 -o futhark/lib/libnetwork_graph.{{if os() == "macos" { "dylib" } else { "so" }}} futhark/out/network_graph.c -lm
@echo ":: futhark kernels OK"

# Check Futhark kernels typecheck
futhark-check:
@echo ":: futhark type-check"
Expand Down
46 changes: 39 additions & 7 deletions ocaml/lib/audit.ml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
(** Hash-chain audit log.
Each entry links to the previous via SHA-256, forming a tamper-evident chain.
Writes to /results/audit.jsonl (one JSON object per line). *)
(** Hash-chain audit log — thin wrapper over verified Hexstrike_Audit.
Adds UUID generation, JSON serialization, and file I/O
on top of the F*-extracted verified hash-chain core. *)

type decision =
| Allowed
Expand All @@ -19,11 +19,39 @@ type entry = {
entry_hash : string;
}

let genesis_hash =
String.make 64 '0'
let genesis_hash = Hexstrike_Audit.genesis_hash

let sha256_hex s =
Sha256.string s |> Sha256.to_hex
let sha256_hex s = Hexstrike_Extern.sha256 s

(** Convert local decision to F*-extracted decision type *)
let to_fstar_decision = function
| Allowed -> Hexstrike_Types.Allowed
| Denied r -> Hexstrike_Types.Denied r

(** Convert local risk string to F*-extracted severity *)
let to_fstar_severity = function
| "Info" -> Hexstrike_Types.Info
| "Low" -> Hexstrike_Types.Low
| "Medium" -> Hexstrike_Types.Medium
| "High" -> Hexstrike_Types.High
| "Critical" -> Hexstrike_Types.Critical
| _ -> Hexstrike_Types.Info

(** Convert F*-extracted audit entry back to local entry *)
let of_fstar_entry (ae : Hexstrike_Audit.audit_entry) ~risk_level_str : entry = {
entry_id = ae.ae_entry_id;
previous_hash = ae.ae_previous_hash;
timestamp = ae.ae_timestamp;
caller = ae.ae_caller;
tool_name = ae.ae_tool_name;
decision = (match ae.ae_decision with
| Hexstrike_Types.Allowed -> Allowed
| Hexstrike_Types.Denied r -> Denied r);
risk_level = risk_level_str;
duration_ms = ae.ae_duration_ms;
result_summary = ae.ae_result;
entry_hash = ae.ae_entry_hash;
}

let entry_payload e =
String.concat "|" [
Expand Down Expand Up @@ -64,6 +92,10 @@ let verify_entry e =
let verify_chain_link ~prev ~curr =
curr.previous_hash = prev.entry_hash && verify_entry curr

(** Also verify using the F*-extracted verifier for double-check *)
let verify_entry_fstar (ae : Hexstrike_Audit.audit_entry) : bool =
Hexstrike_Audit.verify_entry ae

let entry_to_json e : Yojson.Safe.t =
`Assoc [
("entryId", `String e.entry_id);
Expand Down
2 changes: 1 addition & 1 deletion ocaml/lib/dune
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
(library
(name hexstrike_lib)
(wrapped false)
(libraries unix yojson sha uuidm logs fmt))
(libraries unix yojson sha uuidm logs fmt ctypes ctypes.foreign))
59 changes: 59 additions & 0 deletions ocaml/lib/fstar_extracted/Hexstrike_Audit.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
(** Extracted from Hexstrike.Audit.fst — hash-chain audit log.
DO NOT EDIT: regenerated by F* extraction in CI.
Requires Hexstrike_Extern.sha256 for the external sha256 function. *)

open Hexstrike_Types

type audit_entry = {
ae_entry_id : string;
ae_previous_hash : string;
ae_timestamp : string;
ae_caller : string;
ae_tool_name : string;
ae_decision : policy_decision;
ae_risk_level : severity;
ae_duration_ms : int;
ae_result : string;
ae_entry_hash : string;
}

let entry_payload e =
String.concat "|" [
e.ae_entry_id;
e.ae_previous_hash;
e.ae_timestamp;
e.ae_caller;
e.ae_tool_name;
(match e.ae_decision with
| Allowed -> "allowed"
| Denied r -> "denied:" ^ r);
string_of_int (severity_to_nat e.ae_risk_level);
string_of_int e.ae_duration_ms;
e.ae_result;
]

let verify_entry e =
e.ae_entry_hash = Hexstrike_Extern.sha256 (entry_payload e)

let verify_chain_link ~prev ~curr =
curr.ae_previous_hash = prev.ae_entry_hash && verify_entry curr

let create_entry ~entry_id ~prev_hash ~timestamp
~caller ~tool_name ~decision ~risk ~duration ~result =
let e_partial = {
ae_entry_id = entry_id;
ae_previous_hash = prev_hash;
ae_timestamp = timestamp;
ae_caller = caller;
ae_tool_name = tool_name;
ae_decision = decision;
ae_risk_level = risk;
ae_duration_ms = duration;
ae_result = result;
ae_entry_hash = "";
} in
let hash = Hexstrike_Extern.sha256 (entry_payload e_partial) in
{ e_partial with ae_entry_hash = hash }

let genesis_hash =
String.make 64 '0'
52 changes: 52 additions & 0 deletions ocaml/lib/fstar_extracted/Hexstrike_Dispatch.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
(** Extracted from Hexstrike.Dispatch.fst — verified dispatch engine.
DO NOT EDIT: regenerated by F* extraction in CI. *)

open Hexstrike_Types
open Hexstrike_Audit

type registry = (string * tool_capability) list

let rec lookup name = function
| [] -> None
| (n, cap) :: tl -> if n = name then Some cap else lookup name tl

type dispatch_outcome = {
do_result : dispatch_result;
do_audit : audit_entry;
}

let dispatch reg pol tc ~prev_hash ~entry_id ~timestamp =
match lookup tc.tc_tool_name reg with
| None ->
let reason = "unknown tool: " ^ tc.tc_tool_name in
let result = DispatchError (tc.tc_tool_name, reason) in
let audit = create_entry ~entry_id ~prev_hash ~timestamp
~caller:tc.tc_caller ~tool_name:tc.tc_tool_name
~decision:(Denied reason) ~risk:Info ~duration:0 ~result:reason in
{ do_result = result; do_audit = audit }
| Some cap ->
match Hexstrike_Sanitize.sanitize tc.tc_target with
| None ->
let reason = "target contains shell metacharacters" in
let result = DispatchDenied (tc.tc_tool_name, reason) in
let audit = create_entry ~entry_id ~prev_hash ~timestamp
~caller:tc.tc_caller ~tool_name:tc.tc_tool_name
~decision:(Denied reason) ~risk:cap.cap_risk_level ~duration:0
~result:reason in
{ do_result = result; do_audit = audit }
| Some _clean_target ->
let decision = Hexstrike_Policy.evaluate_policy pol tc cap in
(match decision with
| Denied reason ->
let result = DispatchDenied (tc.tc_tool_name, reason) in
let audit = create_entry ~entry_id ~prev_hash ~timestamp
~caller:tc.tc_caller ~tool_name:tc.tc_tool_name
~decision ~risk:cap.cap_risk_level ~duration:0 ~result:reason in
{ do_result = result; do_audit = audit }
| Allowed ->
let result = DispatchOk tc.tc_tool_name in
let audit = create_entry ~entry_id ~prev_hash ~timestamp
~caller:tc.tc_caller ~tool_name:tc.tc_tool_name
~decision:Allowed ~risk:cap.cap_risk_level ~duration:0
~result:"dispatched" in
{ do_result = result; do_audit = audit })
4 changes: 4 additions & 0 deletions ocaml/lib/fstar_extracted/Hexstrike_Extern.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
(** External functions required by F* extracted code.
Satisfies `assume val sha256 : string -> string` in Hexstrike.Audit.fst. *)

let sha256 s = Sha256.string s |> Sha256.to_hex
22 changes: 22 additions & 0 deletions ocaml/lib/fstar_extracted/Hexstrike_Policy.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
(** Extracted from Hexstrike.Policy.fst — policy evaluation.
DO NOT EDIT: regenerated by F* extraction in CI. *)

open Hexstrike_Types

let rec mem x = function
| [] -> false
| hd :: tl -> if hd = x then true else mem x tl

let evaluate_policy pol tc cap =
if mem tc.tc_tool_name pol.pol_denied_tools then
Denied "tool is explicitly denied by policy"
else if not (severity_leq cap.cap_risk_level pol.pol_max_risk_level) then
Denied "tool risk level exceeds policy maximum"
else
match pol.pol_allowed_tools with
| [] -> Allowed
| _ ->
if mem tc.tc_tool_name pol.pol_allowed_tools then
Allowed
else
Denied "tool is not in the allowed list"
20 changes: 20 additions & 0 deletions ocaml/lib/fstar_extracted/Hexstrike_Sanitize.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
(** Extracted from Hexstrike.Sanitize.fst — input sanitization.
DO NOT EDIT: regenerated by F* extraction in CI. *)

let shell_metachars =
[ '|'; '&'; ';'; '$'; '`'; '('; ')'; '{'; '}'; '<'; '>'; '\n'; '\r' ]

let is_shell_meta c = List.mem c shell_metachars

let has_shell_meta s =
let len = String.length s in
let rec check i =
if i >= len then false
else if is_shell_meta s.[i] then true
else check (i + 1)
in
check 0

let sanitize s =
if has_shell_meta s then None
else Some s
54 changes: 54 additions & 0 deletions ocaml/lib/fstar_extracted/Hexstrike_Types.ml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
(** Extracted from Hexstrike.Types.fst — core types with risk ordering.
DO NOT EDIT: regenerated by F* extraction in CI. *)

type severity =
| Info
| Low
| Medium
| High
| Critical

let severity_to_nat = function
| Info -> 0
| Low -> 1
| Medium -> 2
| High -> 3
| Critical -> 4

let severity_leq a b = severity_to_nat a <= severity_to_nat b

type policy_decision =
| Allowed
| Denied of string

type audit_level =
| Minimal
| Standard
| Verbose

type tool_call = {
tc_tool_name : string;
tc_caller : string;
tc_target : string;
tc_request_id : string;
}

type tool_capability = {
cap_name : string;
cap_category : string;
cap_risk_level : severity;
cap_max_exec_secs : int;
}

type policy = {
pol_name : string;
pol_allowed_tools : string list;
pol_denied_tools : string list;
pol_max_risk_level : severity;
pol_audit_level : audit_level;
}

type dispatch_result =
| DispatchOk of string
| DispatchDenied of string * string
| DispatchError of string * string
Loading
Loading