# 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 ```bash 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 [priority

]` form. --- ## Policy Hook Syntax The `on { hook = ..., table = ..., priority = ... }` block is gone. Policies now use: ```fwl 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.md` → *Policy 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": ` — 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