maelys-git
███╗ ███╗ █████╗ ███████╗██╗ ██╗ ██╗███████╗ ██████╗ ██╗████████╗ ████╗ ████║██╔══██╗██╔════╝██║ ╚██╗ ██╔╝██╔════╝ ██╔════╝ ██║╚══██╔══╝ ██╔████╔██║███████║█████╗ ██║ ╚████╔╝ ███████╗ ██║ ███╗██║ ██║ ██║╚██╔╝██║██╔══██║██╔══╝ ██║ ╚██╔╝ ╚════██║ ██║ ██║██║ ██║ ██║ ╚═╝ ██║██║ ██║███████╗███████╗██║ ███████║ ╚██████╔╝██║ ██║ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝
maelys-git is a C wrapper around the git binary. Before any git command
runs, it evaluates a Datalog policy to decide whether the operation is allowed.
This wrapper runs before git is exec'd, so git hooks and --no-verify are
irrelevant to denied operations: a denied operation never reaches git at all.
This tutorial builds the wrapper in five levels of increasing rigour.
Why not git hooks
git hook (.git/hooks/) maelys-git
──────────────────────────────────────────────────────────────
Shell scripts, not versioned Datalog in .maelys/git/policy.dl
Bypassable via --no-verify Enforced before git runs
One hook per event One policy for all operations
No audit trail Receipt per decision, with proof
Hard to test Testable with edb_add_fact
Silent conflicts Explicit deny rules + stratified negationLevel 1 — Minimal gate
Level 1 introduces the core pattern: turn a Git command into runtime facts,
evaluate a Datalog policy, and only execute the real git binary if the
policy derives allow(Op). This level deliberately keeps the domain small:
operation, target ref, user, flags, and commit-message validity, with a small
allowlist of known operations. It is the minimal policy gate, and it is
fail-closed: anything it cannot determine with certainty is denied.
The pipeline
Domain
/* Minimal domain for Level 1. Level 3 extends this with
source_ref, changed_file, sensitive_file_staged,
approval_present, working_tree_dirty, branch_class. */
static const maelys_datalog_predicate_def_t git_preds[] = {
/* EDB: runtime command context */
{ "operation", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "target_ref", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "user", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "flag", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "commit_msg", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "valid_commit_msg", 1, MAELYS_DATALOG_PRED_KIND_EDB },
/* POLICY_FACT: ruleset configuration */
{ "protected_branch", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "blocked_user", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "release_manager", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "known_operation", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
/* IDB / QUERY: decisions.
deny/2 : query-visible for receipt/debug deny-code enumeration.
deny_any/1 : internal collapse predicate used by allow/1.
allow/1 : queried by the wrapper to make the decision. */
{ "deny", 2, MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERY },
{ "deny_any", 1, MAELYS_DATALOG_PRED_KIND_IDB },
{ "allow", 1, MAELYS_DATALOG_PRED_KIND_IDB |
MAELYS_DATALOG_PRED_KIND_QUERY },
};Policy
protected_branch("main").
protected_branch("master").
protected_branch("release").
blocked_user("ci-bot").
release_manager("alice").
release_manager("bob").
known_operation("push").
known_operation("commit").
/* Unknown operation: denied by default, not allowed because no deny matched. */
deny(Op, "GIT_UNKNOWN_OPERATION") :-
operation(Op), not(known_operation(Op)).
/* Force covers --force, -f, --force-with-lease, --force-if-includes and a
leading '+' in a refspec: the C side normalizes them all to flag("force"). */
deny("push", "GIT_FORCE_PUSH") :-
operation("push"), flag("force").
deny("push", "GIT_PROTECTED_BRANCH") :-
operation("push"), target_ref(B), protected_branch(B),
user(U), not(release_manager(U)).
/* Delete covers --delete, -d, -D and a leading ':' in a refspec:
the C side normalizes them all to flag("delete"). */
deny("push", "GIT_DELETE_PROTECTED") :-
operation("push"), flag("delete"), target_ref(B), protected_branch(B).
deny("commit", "GIT_BLOCKED_USER") :-
operation("commit"), user(U), blocked_user(U).
deny("commit", "GIT_BAD_COMMIT_PREFIX") :-
operation("commit"), commit_msg(M), not(valid_commit_msg(M)).
deny_any(Op) :- deny(Op, Code).
/* allow requires known_operation(Op): an unlisted operation is denied,
never allowed merely because no deny rule happened to match it. */
allow(Op) :- operation(Op), known_operation(Op), not(deny_any(Op)).Allowlist, not allow-by-default. allow(Op) requires known_operation(Op).
Without it, the classic trap is that any operation with no matching deny rule
is allowed automatically. Here, an operation the policy does not list is
denied (GIT_UNKNOWN_OPERATION). Note that merge is not listed: git merge feature merges into the current branch, which is not in argv, so Level 1
cannot determine the protected ref and denies merge as unknown. See the
refspec and identity notes below.
The deny reason is a stable code (GIT_FORCE_PUSH, GIT_PROTECTED_BRANCH),
not a human-readable string. Human messages live in a C lookup table and can
change without touching the policy or breaking receipts. See Level 2.
Policy vocabulary vs runtime evidence.
This policy uses string constants directly in the .dl source: "push",
"force", "GIT_FORCE_PUSH", "main", etc. In Maelys DL, every string
written in the .dl source must belong to the registered atom vocabulary
before parsing. In manifest/closed-vocabulary mode (the pattern used here),
this is handled by the manifest loader.
Runtime values — user name, branch name, commit message — are different:
they are unknown at policy-load time and are injected separately into the EDB
via edb_add_runtime_symbol_fact. They do not need to be pre-registered.
Policy source → "push", "GIT_FORCE_PUSH", "--force" → atom vocabulary
Runtime EDB → user("alice"), target_ref("main") → edb_add_runtime_symbol_factEDB from argv
Every insertion is checked. A gate cannot silently drop a fact: if
operation("push") fails to insert, no deny rule can fire and allow/1
becomes derivable by default — a missing fact would turn a deny into an
allow. On any failure we return the error and the caller fails closed.
static maelys_result_t
populate_edb(maelys_datalog_edb_t *edb, const git_context_t *ctx)
{
maelys_result_t rc;
/* operation is mandatory; its failure must fail the gate */
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "operation", ctx->operation);
if (rc != MAELYS_OK) return rc;
if (ctx->user) {
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "user", ctx->user);
if (rc != MAELYS_OK) return rc;
}
if (ctx->target_ref) {
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "target_ref", ctx->target_ref);
if (rc != MAELYS_OK) return rc;
}
if (ctx->has_force) {
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "flag", "force");
if (rc != MAELYS_OK) return rc;
}
if (ctx->has_delete) {
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "flag", "delete");
if (rc != MAELYS_OK) return rc;
}
if (ctx->commit_msg) {
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "commit_msg", ctx->commit_msg);
if (rc != MAELYS_OK) return rc;
static const char *ok[] = { "feat:", "fix:", "chore:", "docs:", "refactor:", NULL };
for (int i = 0; ok[i]; i++)
if (strncmp(ctx->commit_msg, ok[i], strlen(ok[i])) == 0) {
rc = maelys_datalog_edb_add_runtime_symbol_fact(edb, "valid_commit_msg", ctx->commit_msg);
if (rc != MAELYS_OK) return rc;
break;
}
}
return maelys_datalog_edb_finalize(edb);
}Check every insertion in a gate. Ignoring the maelys_result_t of
edb_add_runtime_symbol_fact is the most common way to break fail-closed: a
dropped operation or flag fact silently weakens the policy. The deny
rules depend on those facts being present.
Evaluate and exec
static int evaluate(const maelys_datalog_policy_set_t *ps,
const git_context_t *ctx) {
static maelys_datalog_edb_t edb;
static maelys_datalog_fact_t pool[256];
if (maelys_datalog_edb_init(&edb, pool, 256,
&ps->policies[0].symbols, &ps->policies[0].registry) != MAELYS_OK)
return 1; /* fail closed */
/* Intern the operation ONCE, before solving, and keep the id.
Reusing it for the query term avoids a second intern after solve
(which would mutate the post-load symbol table) and guarantees the
query uses the same id the EDB was built with. */
maelys_datalog_symbol_id_t op_id;
if (maelys_datalog_edb_intern_runtime_symbol(&edb, ctx->operation, &op_id) != MAELYS_OK)
return 1; /* fail closed */
if (populate_edb(&edb, ctx) != MAELYS_OK)
return 1; /* fail closed */
maelys_datalog_solve_result_t *result = NULL;
if (maelys_datalog_solve_once(&ps->policies[0], &edb, &result) != MAELYS_OK)
return 1;
maelys_datalog_term_t op_term = {
.kind = MAELYS_DATALOG_TERM_SYMBOL, .as.symbol = op_id };
bool allowed = false;
maelys_result_t rc = maelys_datalog_query_solved_ground_fact(
result, "allow", &op_term, 1, &allowed);
maelys_datalog_solve_result_free(result);
if (rc != MAELYS_OK) return 1; /* fail closed */
if (!allowed) {
fprintf(stderr, "maelys-git: '%s' denied by policy\n", ctx->operation);
return 1;
}
return 0;
}Intern once, query with the same id. The operation string is interned
during EDB build and the returned id is reused to build the allow(Op) query
term. Re-interning after solve_once would write into the symbol table the
ruleset shares, mutating state that should be stable after load. For an
unknown operation the intern still succeeds (a new runtime symbol), but no
allow fact is derived for it, so the gate denies — fail-closed by default.
Why this is fail-closed by construction
The wrapper above is short, but three of its details are not stylistic — the engine forces them, and getting any of them wrong fails loudly rather than silently weakening the gate. They are worth calling out because they are the reason a Maelys gate is hard to write insecurely.
Lifecycle order intern BEFORE finalize, not after
Read-only query reuse the id, never intern post-solve
No silent loss every insertion return is checkedLifecycle: intern before finalize. The EDB has a state machine. Once
edb_finalize runs (inside populate_edb), the EDB is immutable and any
further intern or insertion returns MAELYS_ERR_INVALID_STATE. That is why
the operation is interned before populate_edb is called. Inverting the two
lines does not "sometimes work" — it returns a hard error, and the gate fails
closed. The engine makes the unsafe ordering impossible to ship by accident.
Query is read-only. After solve_once, the result is queried by building
a maelys_datalog_term_t from the id obtained earlier. The query API never
mutates: it does not intern, it does not touch the symbol table. Re-interning
the operation string after the solve would write into the table the ruleset
shares, which is exactly the post-load mutation the engine's design forbids.
Reusing the id keeps the decision path 100% read-only.
No silent capacity loss. Every edb_add_runtime_symbol_fact return is
checked. If the static fact pool were exhausted (unlikely with Git arguments,
but bounded and therefore possible), the insertion returns an error, the
wrapper denies, and Git never runs. There is no truncated EDB that quietly
solves to allow because a fact silently failed to insert.
This is the general shape of a Maelys integration: the engine's invariants (strict lifecycle, read-only queries, transactional capacity, zero-malloc static pools) push the caller toward a fail-closed implementation. A bug in any of the three points above surfaces as an error code, not as a silent allow. The same invariants appear in maelys-pty and document access.
What the engine does not protect you from. The three guarantees above
are narrow: the engine makes a specific class of bug loud instead of silent
— acting after finalize, mutating during a query, or a dropped insertion you
chose to check. It does not make arbitrary code safe, and two limits matter.
First, the capacity guarantee only helps if you read the return value. The
engine hands you an error code; it cannot force you to test it. Write
edb_add_runtime_symbol_fact(edb, "user", u); and ignore the result, and a
dropped fact can still turn a deny into an allow. The engine did not save you
— it returned the error you threw away.
Second, and more important: the engine guarantees that if your facts are
correct, the decision is correct. It guarantees nothing about whether your
facts are true. Every context bug in this tutorial — binding target_ref to
the remote instead of the branch, treating git push origin as unambiguous,
reading identity from a spoofable $USER — produces a valid allow over a
well-formed EDB. The EDB simply asserts the wrong thing about the world, and
the solver faithfully reasons from it. Garbage in, valid garbage out.
So the work that actually secures the gate — parsing argv correctly, normalizing refspecs, refusing ambiguity, trusting a real identity source — lives entirely in the C that builds the EDB. The engine protects the decision; you are still responsible for the facts.
Load policy and run
Level 1 keeps the policy compiled in and loads it with
load_policy_inline_with_static_domain (static predicate table + inline
source in one call). Level 4 replaces this with
manifest_load_ex and SHA-256 verification.
int main(int argc, char *argv[]) {
/* Load the compiled-in policy. Any load error fails the gate closed. */
maelys_datalog_policy_set_t ps;
maelys_datalog_diagnostic_t diag = {0};
maelys_result_t rc = maelys_datalog_load_policy_inline_with_static_domain(
git_preds, sizeof(git_preds) / sizeof(git_preds[0]),
"git_policy", "git_policy.l1",
POLICY_SRC, sizeof(POLICY_SRC) - 1u,
0, &ps, &diag);
if (rc != MAELYS_OK) {
fprintf(stderr, "maelys-git: policy load error: %s\n",
diag.message[0] ? diag.message : "unknown");
return 1; /* fail closed */
}
git_context_t ctx;
extract_context(argc, argv, &ctx);
if (!ctx.operation) {
maelys_datalog_policy_set_clear(&ps);
return 1;
}
/* Fail-closed preconditions: make the absence (or ambiguity) of a fact
impossible for the rules that need it. A missing user, an unreadable
commit message, or an undeterminable push ref must never let the
corresponding deny rule silently fail to fire. */
if (!ctx.user || ctx.user[0] == '\0') {
fprintf(stderr, "maelys-git: missing user identity\n");
maelys_datalog_policy_set_clear(&ps);
return 1;
}
if (strcmp(ctx.operation, "commit") == 0 &&
(!ctx.commit_msg || ctx.commit_msg[0] == '\0')) {
fprintf(stderr, "maelys-git: commit message not available to gate\n");
maelys_datalog_policy_set_clear(&ps);
return 1;
}
if (ctx.needs_ref && ctx.ref_ambiguous) {
fprintf(stderr, "maelys-git: push target ref is ambiguous or missing\n");
maelys_datalog_policy_set_clear(&ps);
return 1;
}
int denied = evaluate(&ps, &ctx);
maelys_datalog_policy_set_clear(&ps);
if (denied) return 1;
/* Avoid PATH loop if maelys-git shadows git in PATH:
set MAELYS_GIT_REAL=/usr/bin/git or use an absolute path.
Also fix argv[0] so real git sees its own name, not the wrapper's. */
const char *real_git = getenv("MAELYS_GIT_REAL");
if (!real_git) real_git = "/usr/bin/git";
argv[0] = (char *)real_git;
execv(real_git, argv);
perror("maelys-git: execv"); /* execv only returns on error */
return 1;
}Parsing the refspec. The protected branch is the destination of the
push, and it must be extracted with certainty or the gate denies. Level 1
counts positionals and requires exactly git push <remote> <refspec> (two of
them): git push origin alone is the remote, not a branch, so the
destination is undeterminable → deny; more than two means multiple refspecs,
which Level 1 does not evaluate one by one → deny. The single refspec is then
normalized — refs/heads/main, HEAD:main, main:main, +main, :main all
reduce to main (with + → force, leading : → delete) — and anything that
cannot be normalized with certainty (tag namespaces, a bare HEAD, exotic
characters) is denied. Capturing the first positional instead would bind
target_ref to the remote ("origin") and silently miss the protected
branch.
A missing fact is a bypass. Several deny rules depend on a fact being
present: GIT_PROTECTED_BRANCH needs user(U), GIT_BAD_COMMIT_PREFIX needs
commit_msg(M). If the user identity is unset, or the commit message lives in
an editor / -F file instead of -m, that fact never enters the EDB, the
deny rule cannot fire, and allow/1 is derivable by default. The gate must
establish these preconditions in C before solving, and deny when it cannot.
That is why the identity and commit-message checks above run before
evaluate. The same reasoning extends to force detection
(--force-with-lease, a leading + in a refspec) and to refspec forms like
HEAD:main or refs/heads/main that must normalize to main before the
protected-branch rule can match.
Beyond Level 1: a fail-closed gate over all of git. This tutorial keeps a
small domain to teach the pattern. A production gate must classify every
subcommand (≈50+), normalize refspecs, skip git global options
(git -C path …, -c key=val), honor the -- pathspec separator, and treat
anything it cannot parse with certainty as deny. The reference implementation
does this with a subcommand→risk-class table (readonly, local_mutate,
publish, history_rewrite, ref_delete, config_mutate, submodule,
plumbing, unknown) and an allowed_class(...) allowlist, so an unknown
subcommand or an un-normalizable refspec injects a signal fact
(context_incomplete, unparsed_token) that the policy denies. See the
standalone maelys_git_gate.c.
Level 1 in action
$ maelys-git push origin main → BLOCKED (GIT_PROTECTED_BRANCH)
$ maelys-git push --force origin feat → BLOCKED (GIT_FORCE_PUSH)
$ maelys-git commit -m "wip" → BLOCKED (GIT_BAD_COMMIT_PREFIX)
$ maelys-git push origin → BLOCKED (ambiguous ref, preflight)
$ maelys-git status → BLOCKED (GIT_UNKNOWN_OPERATION)
$ maelys-git push origin feat/foo → ALLOWED → execv("/usr/bin/git", argv)git status is blocked because Level 1's allowlist is push and commit
only — read-only commands are not listed, so they are denied as unknown. That
is the strict posture; to permit reads, add them to known_operation(...), or
use the risk-class gate (maelys_git_gate.c) which allows a whole readonly
class at once.
Level 1 queries allow(Op) only. It knows the operation was denied but not
which deny code fired. Retrieving the exact code (GIT_FORCE_PUSH,
GIT_PROTECTED_BRANCH, etc.) requires enumerating deny/2 results or using
the receipt API. Both are shown in Level 5.
Level 2 — Structured deny codes
Level 2 separates machine-stable decision codes from human-readable messages.
The policy derives facts such as deny("push", "GIT_FORCE_PUSH"), while the
wrapper maps GIT_FORCE_PUSH to a user-facing message. This keeps receipts
stable even when wording, localization, or UI messages change.
The principle
deny("push", "GIT_FORCE_PUSH") ← stable code in policy
↓
C lookup table:
"GIT_FORCE_PUSH" → "force-push is forbidden"
"GIT_PROTECTED_BRANCH" → "direct push to protected branch"
"GIT_RELEASE_BRANCH" → "only release managers can push to release branches"
"GIT_BLOCKED_USER" → "user is not allowed to commit"
"GIT_BAD_COMMIT_PREFIX" → "commit message must start with feat: fix: ..."
"GIT_DELETE_PROTECTED" → "deleting a protected branch is forbidden"
"GIT_SENSITIVE_FILE" → "this file requires explicit approval"Human messages can change without touching the policy. The deny code is what appears in receipts and audit logs — it is stable across policy versions.
Message table in C
static const struct { const char *code; const char *message; }
deny_messages[] = {
{ "GIT_FORCE_PUSH", "force-push is forbidden" },
{ "GIT_PROTECTED_BRANCH", "direct push to protected branch is forbidden" },
{ "GIT_BLOCKED_USER", "user is not allowed to commit" },
{ "GIT_BAD_COMMIT_PREFIX", "commit message must start with feat: fix: chore: docs: refactor:" },
{ "GIT_DELETE_PROTECTED", "deleting a protected branch is forbidden" },
{ "GIT_SENSITIVE_FILE", "this file requires explicit approval" },
{ "GIT_RELEASE_BRANCH", "only release managers can push to release branches" },
{ NULL, NULL }
};
static const char *message_for_code(const char *code) {
for (int i = 0; deny_messages[i].code; i++)
if (strcmp(deny_messages[i].code, code) == 0)
return deny_messages[i].message;
return code; /* fallback: show code if unknown */
}Level 3 — Rich Git context
Level 3 enriches the EDB with facts extracted from Git state: staged files, branch families, working-tree status, sensitive-file classification, and approval markers. The wrapper performs string matching and Git inspection in C; the Datalog policy only reasons over already-classified facts. This keeps the policy deterministic, bounded, and auditable.
The "no string builtins" principle
Maelys DL has no starts_with(), no regex(), no glob(). The wrapper
extracts contextual properties into EDB facts. The policy only decides.
Instead of: starts_with(File, "src/core/") ← not in Datalog
Inject instead: sensitive_file_staged("src/core/policy.c") ← EDB fact
Instead of: regex(Branch, "release/.*") ← not in Datalog
Inject instead: branch_class("release") ← EDB fact from CThis keeps the policy bounded, deterministic, and auditable.
Extended domain
static const maelys_datalog_predicate_def_t git_preds_l3[] = {
{ "operation", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "target_ref", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "source_ref", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "user", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "flag", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "commit_msg", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "valid_commit_msg", 1, MAELYS_DATALOG_PRED_KIND_EDB },
/* Level 3: richer context — C extracts, policy decides */
{ "changed_file", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "sensitive_file_staged",1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "approval_present", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "working_tree_dirty", 1, MAELYS_DATALOG_PRED_KIND_EDB },
{ "branch_class", 1, MAELYS_DATALOG_PRED_KIND_EDB },
/* POLICY_FACT */
{ "protected_branch", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "blocked_user", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
{ "release_manager", 1, MAELYS_DATALOG_PRED_KIND_POLICY_FACT },
/* sensitive_path is not needed: C classifies files into sensitive_file_staged */
/* IDB */
{ "deny", 2, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },
{ "deny_any",1, MAELYS_DATALOG_PRED_KIND_IDB },
{ "allow", 1, MAELYS_DATALOG_PRED_KIND_IDB | MAELYS_DATALOG_PRED_KIND_QUERY },
};Extended policy
/* Sensitive path classification is done in C.
C injects sensitive_file_staged(F) for staged files matching sensitive paths.
Maelys DL has no starts_with() or glob(). */
/* ── Force push ─────────────────────────────────────────────── */
deny("push", "GIT_FORCE_PUSH") :- operation("push"), flag("--force").
deny("push", "GIT_FORCE_PUSH") :- operation("push"), flag("-f").
/* ── Protected branch (non-managers only) ───────────────────── */
deny("push", "GIT_PROTECTED_BRANCH") :-
operation("push"), target_ref(B), protected_branch(B),
user(U), not(release_manager(U)).
/* ── Release branch family (C injects branch_class("release") for release/*) ── */
/* branch_class("release") covers release/v1, release/v2, etc.
protected_branch("release") above covers only the branch named exactly "release".
These two rules are complementary, not redundant. */
deny("push", "GIT_RELEASE_BRANCH") :-
operation("push"),
branch_class("release"),
user(U),
not(release_manager(U)).
/* ── Blocked user ───────────────────────────────────────────── */
deny("commit", "GIT_BLOCKED_USER") :-
operation("commit"), user(U), blocked_user(U).
/* ── Commit message prefix ──────────────────────────────────── */
deny("commit", "GIT_BAD_COMMIT_PREFIX") :-
operation("commit"), commit_msg(M), not(valid_commit_msg(M)).
/* ── Sensitive file requires approval ───────────────────────── */
deny("commit", "GIT_SENSITIVE_FILE") :-
operation("commit"),
sensitive_file_staged(F),
not(approval_present(F)).
/* ── Dirty working tree cannot be pushed ───────────────────── */
deny("push", "GIT_DIRTY_TREE") :-
operation("push"),
working_tree_dirty("true").
/* ── deny_any / allow ───────────────────────────────────────── */
deny_any(Op) :- deny(Op, Code).
allow(Op) :- operation(Op), not(deny_any(Op)).Extended EDB population
/* C extracts contextual properties — policy never does string matching */
if (ctx->source_ref)
maelys_datalog_edb_add_runtime_symbol_fact(edb, "source_ref", ctx->source_ref);
/* Branch family: C does prefix match, policy sees branch_class fact */
if (ctx->target_ref && strncmp(ctx->target_ref, "release/", 8) == 0)
maelys_datalog_edb_add_runtime_symbol_fact(edb, "branch_class", "release");
/* Staged files from git diff --cached --name-only */
for (int i = 0; i < n_staged; i++) {
maelys_datalog_edb_add_runtime_symbol_fact(edb, "changed_file", staged[i]);
if (is_sensitive_path(staged[i])) /* C prefix check */
maelys_datalog_edb_add_runtime_symbol_fact(edb, "sensitive_file_staged", staged[i]);
if (has_approval(staged[i]))
maelys_datalog_edb_add_runtime_symbol_fact(edb, "approval_present", staged[i]);
}
/* Working tree state */
if (is_working_tree_dirty())
maelys_datalog_edb_add_runtime_symbol_fact(edb, "working_tree_dirty", "true");Trustworthy identity: not from the environment
Level 1 reads the user from getenv("GIT_COMMITTER_NAME") then getenv("USER").
That is fine for a demo, but environment variables are attacker-controlled: any
caller can run USER=alice maelys-git push … and impersonate a release
manager. A real gate must derive identity from the operating system, not from
the environment it was handed.
#include <unistd.h>
#include <pwd.h>
/* Real identity: the uid the process runs as, resolved to a name.
Not spoofable through the environment. */
static const char *real_user(void) {
struct passwd *pw = getpwuid(getuid());
return pw ? pw->pw_name : NULL;
}Identity must not come from $USER. getenv("USER") / getenv("GIT_COMMITTER_NAME")
are trivially overridden on the command line, so a policy that trusts them to
identify release managers or blocked users is bypassable. From Level 3 on,
resolve identity with getpwuid(getuid()) (or your platform's authenticated
identity source) before injecting user(U) into the EDB. The Datalog rules do
not change — only where the C side gets the name it trusts.
Level 4 — Per-repository policy
Level 4 moves the policy from compiled-in source to a repository-local
.maelys/git/ directory. Each repository can carry its own manifest, policy
file, SHA verification, and optional signature. Policy updates become normal
reviewed commits or pull requests, and a tampered policy fails closed at load
time.
Repository layout
.maelys/
git/
manifest.json ← describes the policy: id, domain, SHA, mode
policy.dl ← the Datalog rules
policy.sig ← detached signature (optional, out of scope here)Manifest
{
"schema_version": 1,
"policies": [
{
"id": "git_policy.main",
"domain": "git_policy",
"source": "policy.dl",
"sha256": "a3f7c291...",
"mode": "enforce"
}
],
"public_queries": ["allow", "deny", "deny_any"]
}Loading at startup
static int load_repo_policy(maelys_datalog_policy_set_t *ps) {
char root[PATH_MAX], manifest_path[PATH_MAX];
if (find_repo_root(root, sizeof(root)) != 0) {
fprintf(stderr, "maelys-git: not in a git repository\n");
return 1;
}
snprintf(manifest_path, sizeof(manifest_path),
"%s/.maelys/git/manifest.json", root);
maelys_datalog_diagnostic_t diag = {0};
if (maelys_datalog_manifest_load_ex(manifest_path, 0, ps, &diag) != MAELYS_OK) {
fprintf(stderr, "maelys-git: policy load error: %s\n", diag.message);
return 1;
}
return 0;
}The manifest loading verifies the SHA-256 of policy.dl against the manifest
entry. A tampered policy file fails to load — the gate stays closed.
Updating the policy
vim .maelys/git/policy.dl
sha256sum .maelys/git/policy.dl | awk '{print $1}' # update sha256 in manifest
vim .maelys/git/manifest.json
git add .maelys/git/
git commit -m "policy: require approval for sensitive files"Policy changes are PRs. The SHA-256 ties the manifest to the exact policy bytes that reviewers approved.
Level 5 — Receipts
Level 5 turns the wrapper into an audited gate. Every allow or deny decision produces a structured receipt containing the operation, user, target ref, policy identity, policy hash, deny code, and proof summary when available. The receipt makes the decision reviewable without relying on shell logs or re-running Git.
Receipt structure
{
"tool": "maelys-git",
"operation": "push",
"target_ref": "main",
"user": "alice",
"decision": "deny",
"deny_code": "GIT_PROTECTED_BRANCH",
"deny_message": "direct push to protected branch is forbidden",
"policy_id": "git_policy.main",
"policy_sha256": "a3f7c291...",
"timestamp": "2025-06-15T14:32:01Z",
"proof_summary": {
"derived": "deny(\"push\", \"GIT_PROTECTED_BRANCH\")",
"facts_used": [
"operation(\"push\")",
"target_ref(\"main\")",
"protected_branch(\"main\")"
]
}
}Emitting the receipt
static void emit_receipt(const git_context_t *ctx,
bool allowed,
const char *deny_code,
const char *policy_id,
const char *policy_sha256)
{
fprintf(stderr,
"{\n"
" \"tool\": \"maelys-git\",\n"
" \"operation\": \"%s\",\n"
" \"target_ref\": \"%s\",\n"
" \"user\": \"%s\",\n"
" \"decision\": \"%s\",\n"
" \"deny_code\": \"%s\",\n"
" \"deny_message\": \"%s\",\n"
" \"policy_id\": \"%s\",\n"
" \"policy_sha256\": \"%s\"\n"
"}\n",
ctx->operation,
ctx->target_ref ? ctx->target_ref : "",
ctx->user ? ctx->user : "",
allowed ? "allow" : "deny",
deny_code ? deny_code : "",
deny_code ? message_for_code(deny_code) : "",
policy_id,
policy_sha256);
}In this example, deny_code is assumed to come from the receipt/proof API or
from an enumeration of deny(Op, Code) facts in the solve result. The minimal
ground-query API (query_solved_ground_fact) only proves allow/deny — it does
not bind the code value directly. The receipt API recovers the exact code.
The proof summary (which rule fired, which facts were used) is produced by the receipt and proof API when enabled. Until canonical proof serialization is complete, the minimal receipt — decision, deny code, policy identity, and selected EDB facts — is already valuable. See Receipts.
Level 5 in action
$ maelys-git push origin main
{
"tool": "maelys-git",
"operation": "push",
"target_ref": "main",
"user": "alice",
"decision": "deny",
"deny_code": "GIT_PROTECTED_BRANCH",
"deny_message": "direct push to protected branch is forbidden",
"policy_id": "git_policy.main",
"policy_sha256": "a3f7c291..."
}
maelys-git: 'push' denied by policyThe full picture
Level 1 argv/env → EDB → solve → allow/deny → execv(real_git, argv)
Level 2 deny codes stable, human messages in C lookup table
Level 3 rich EDB: files, classes, tree state; C extracts, policy decides
Level 4 .maelys/git/manifest.json + policy.dl versioned in repo + SHA verify
Level 5 receipt per decision: deny code, policy hash, proof summaryThis is the exact pattern Maelys uses for agent orchestration:
observe context → project into facts → apply deterministic policy
→ produce decision → emit receipt → execute only if allowedWhat you have learned
- deny/2 uses stable codes (GIT_FORCE_PUSH), not human strings.
Human messages live in a C table and can change without touching policy.
- Maelys DL has no string builtins. The wrapper extracts contextual
properties (sensitive_file_staged, branch_class, working_tree_dirty("true")).
The policy only decides. This keeps it bounded and auditable.
- working_tree_dirty uses arity 1 with value "true" — arity 0 is not used.
- deny_any/1 is needed because not(deny(Op, _)) uses _ as an unbound
variable in a negated literal — rejected by the rule safety checker.
- valid_commit_msg(M) must carry the actual message, not "ok".
- Check every edb_add_runtime_symbol_fact return in a gate. A silently
dropped fact (operation, flag) can turn a deny into an allow.
- Intern the operation once before solving and reuse the id for the query.
Re-interning after solve mutates the ruleset's shared symbol table.
- For push, the gate requires exactly `git push <remote> <refspec>` and
normalizes the refspec (refs/heads/main, HEAD:main, +main, :main -> main).
`git push origin` alone, or multiple refspecs, are ambiguous -> deny.
- A gate must establish required facts in C before solving: user identity for
mutating ops, and the commit message for commit. Deny when they are absent,
or a missing fact silently turns a deny into an allow.
- A production gate classifies every subcommand into a risk class and uses an
allowed_class allowlist, so unknown subcommands and un-normalizable refspecs
fail closed. See the standalone maelys_git_gate.c.
- Use execv(real_git, argv) with an absolute path, not execvp("git"),
to avoid an infinite loop if maelys-git shadows git in PATH.
- Per-repo policy (.maelys/git/) makes each repository self-contained.
Policy changes are PRs. SHA mismatch fails the gate.
- Level 1 only knows allow/deny. The exact deny code requires Level 5
receipts or deny/2 enumeration.
- Receipts make every decision auditable: deny code, policy identity,
proof summary — via the receipt/proof API when enabled, without re-running the solver.Where to go next
- maelys-pty — the same pattern applied to an interactive shell with forkpty and seccomp/execve tracing.
- Document access — ABAC for document ownership, sharing, and sensitivity levels.
- Manifest loading — full reference for
.maelys/git/manifest.jsonformat, SHA verification, and signing. - Receipts — structured proof trees and audit receipts per decision.
- Rulesets — full policy language reference.