Files
fwl/AGENTS.md
Yuri 55c1d347e6 doc: update grammar spec and AGENTS.md for v2 design decisions
- policyDecl: replace verbose on{hook,table,priority} block with
  compact `hook <Hook> [priority <P>]` syntax; table is inferred
  from hook, priority defaults to canonical value for that hook
- Add portforwardDecl and masqueradeDecl top-level declarations
- Add implicit injection rules for stateful/loopback/ndp to
  compiler behaviour section (MVP; importable builtins deferred)
- Remove nat_prerouting / nat_postrouting from canonical policy
  example (replaced by portforward/masquerade declarations)
- Update reserved keywords: add portforward, masquerade, hook (was
  already reserved), priority (was already reserved); remove table
  as a reserved word since it no longer appears in policyDecl
- AGENTS.md: update architecture notes, reserved-words rule, and
  boundaries to reflect new declarations and compiler synthesis
2026-05-04 02:15:58 -07:00

9.0 KiB

AGENTS.md

FWL (Firewall Language) is a Haskell DSL that compiles to nftables JSON. Stack: GHC 9.10.3, Cabal, Parsec 3.x, Aeson 2.x, Tasty/HUnit for tests.


Key Commands

cabal build              # build everything
cabal test               # run all test suites
cabal run fwlc -- check examples/simple-router.fwl    # parse + type-check a source file
cabal run fwlc -- compile examples/simple-router.fwl  # emit nftables JSON to stdout
cabal run fwlc -- pretty examples/simple-router.fwl   # pretty-print the parsed AST

Run tests before marking any task complete. The test suite is cabal test.


Project Structure

fwl/
├── AGENTS.md
├── doc/
│   ├── proposal.md              <- initial design document and exploration
│   ├── fwl_grammar.md           <- authoritative grammar reference; keep in sync with Parser.hs
│   └── ref/
│       ├── ruleset.nft          <- example nftables ruleset
│       └── ruleset.json         <- the same example nftables ruleset in json format
├── examples/
│   ├── simple-router.fwl        <- canonical simple example; must parse and compile cleanly
│   ├── simple-router.nft        <- compiled nftables text output of simple-router.fwl
│   └── router.fwl               <- full router example with WireGuard detection
├── src/FWL/
│   ├── AST.hs                   <- all data types; source of truth for the AST
│   ├── Lexer.hs                 <- Parsec TokenParser, reservedNames, reservedOpNames
│   ├── Parser.hs                <- top-level parser, all sub-parsers
│   ├── Pretty.hs                <- AST -> FWL source (round-trip printer)
│   ├── TypeCheck.hs             <- effect row checker, exhaustiveness, CIDR intervals
│   ├── Interpret.hs             <- evaluator + effect dispatch
│   ├── Compile.hs               <- AST -> nftables JSON (Aeson Value)
│   └── Util.hs                  <- shared helpers
└── test/
    ├── Main.hs
    ├── ParserTests.hs
    ├── TypeCheckTests.hs
    └── CompileTests.hs

The grammar document at doc/fwl_grammar.md must stay in sync with Parser.hs and Lexer.hs. When changing the parser, update the grammar doc in the same commit.


Architecture

The pipeline is strictly linear with no back-edges:

source text
  -> Lexer (Text.Parsec.Token)
  -> Parser   -> [Decl]      (AST.hs)
  -> TypeCheck -> TypedDecl
  -> Compile   -> Aeson Value  (nftables JSON)

The interpreter (Interpret.hs) runs the policy against a mock packet environment and is separate from the compiler. It uses the same typed AST.

Compiler Synthesis

These constructs are synthesised by Compile.hs and do not appear directly in the nftables output as user-written rules:

FWL construct Synthesised nftables output
portforward decl A named map, a nat hook prerouting priority dstnat chain with fib daddr type local guard and dnat ip to ... map rewrite, and a ct status dnat accept rule injected into every Forward chain in the file.
masquerade decl A nat hook postrouting priority srcnat chain with ip saddr @set masquerade rule.
Filter hook policy ct state { established, related } accept (stateful), iifname "lo" accept (loopback), and meta nfproto ipv6 ip6 nexthdr ipv6-icmp ip6 saddr fe80::/10 accept (NDP) are prepended automatically to every Input, Forward, and Output chain before user-written rules.

These injections are intentional and documented in doc/fwl_grammar.md. Do not remove them without updating the grammar document and all affected tests.


Reserved Words Rule

Only syntactic keywords belong in reservedNames in Lexer.hs. A word is a syntactic keyword if and only if Parser.hs uses reserved "word" for it.

Semantic values — action constructors (Allow, Drop, Masquerade), effect labels (Log, Warn, Error), result constructors (Matched, Unmatched), and type names (Frame, FlowPattern, Action) — must NOT be in reservedNames. They are parsed as plain identifiers so they can appear in type, pattern, and expression positions without causing parse errors.

If you add a new keyword: add it to both reservedNames in Lexer.hs AND use reserved "word" in Parser.hs. Never add a word to only one place.

Current reserved keywords:

config    interface  zone      import    from
let       in        pattern    flow      rule      policy    on
case      of        if         then      else      do        perform
within    as        dynamic    cidr4     cidr6
hook      priority
portforward  masquerade
WAN       LAN       WireGuard
Input     Forward   Output     Prerouting  Postrouting
Filter    NAT       Mangle     DstNat      SrcNat
Raw       ConnTrack
true      false

table is not a reserved keyword — it was removed when policyDecl switched from the verbose on { hook = ..., table = ..., priority = ... } syntax to the compact hook <Hook> [priority <P>] form.


Policy Hook Syntax

The on { hook = ..., table = ..., priority = ... } block is gone. Policies now use:

policy name : Frame hook Input = { ... };
-- or with a non-default priority:
policy name : Frame hook Prerouting priority Mangle = { ... };

The table is inferred from the hook; the priority defaults to the canonical value for that hook. See doc/fwl_grammar.mdPolicy Declaration for the full hook-to-table-to-priority mapping.


IP Address Representation

IP addresses are stored as plain Integer in the AST (see AST.hs):

  • IPv4: 32-bit value in the low 32 bits of Integer.
  • IPv6: 128-bit value. All standard notations are supported including :: compression and embedded IPv4 (e.g. ::ffff:192.168.1.1).
  • CIDR: (Literal, Int) — base address literal + prefix length.
  • Validation: host bits must be zero: (addr .&. hostMask prefix bits) == 0.

Use ipv4Lit a b c d from AST.hs to construct IPv4 literals in tests. Never use tuple (Word8, Word8, Word8, Word8) — that type is gone.


Priority

Priority is newtype Priority = Priority { priorityValue :: Int }. Named constants are resolved at parse time in priorityP:

Name Value
Raw -300
ConnTrack -200
Mangle -150
DstNat -100
Filter 0
SrcNat 100

The compiler emits "prio": <int> — always an integer in the nftables JSON, never a string. Do not use the old priorityStr function (deleted).


Parser Conventions

  • All blocks use explicit { } delimiters with trailing ; on each item. endBy p semi (not semiSep) is used wherever trailing semicolons are expected.
  • mapLit must be tried before setLit in atom — both start with { and mapLit consumes { expr -> expr } which setLit would misparse.
  • framePat must be wrapped in try in the pat alternatives — it is a reserved-word-prefixed parser that can fail after consuming input.
  • Port literals (:22, :8080) in record field patterns use fieldLiteral, not literal — the base literal parser does not handle :N syntax.
  • Frame and FlowPattern are NOT in reservedNames; they appear as type names and must be accepted by identifier.
  • portforward and masquerade are in reservedNames; their parsers (portforwardDeclP, masqueradeDeclP) must use reserved for these words.

Testing Conventions

  • Test files use {-# LANGUAGE OverloadedStrings #-} — required because A.String expects Data.Text.Text, not String.
  • IP address assertions use LIP IPv4 n / LIP IPv6 n, not the old LIPv4 (a,b,c,d) tuple constructors.
  • Priority assertions use Priority n directly, e.g. Priority 0, Priority (-100).
  • All parse tests must compile and pass before any PR is merged.
  • CompileTests.hs must include tests for portforward and masquerade synthesis (synthesised chain names, injected ct status dnat accept, injected stateful/loopback/ndp rules).

Boundaries

Safe to do without asking

  • Read any file, list directories
  • Run cabal build, cabal test, cabal run fwlc
  • Edit src/, test/, examples/, doc/
  • Add new test cases to existing test files

⚠️ Ask first

  • Add or remove Cabal dependencies (fwl.cabal)
  • Rename or delete source modules
  • Change the nftables JSON schema emitted by Compile.hs
  • Modify examples/simple-router.fwl or examples/router.fwl in ways that change their semantics
  • Add new compiler-injected rules (stateful, loopback, ndp, or new ones)

🚫 Never

  • Add semantic value names (Allow, Drop, Log, etc.) to reservedNames
  • Add table to reservedNames — it is not a keyword in the current grammar
  • Break the cabal test suite
  • Emit nftables "prio" as a string — it must always be an integer
  • Remove the implicit stateful/loopback/ndp injections from filter-hook chain compilation without updating the grammar doc and all tests