6.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/router.fwl # parse + type-check a source file
cabal run fwlc -- compile examples/router.fwl # emit nftables JSON to stdout
cabal run fwlc -- pretty examples/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/
│ └── router.fwl ← canonical example; must parse and compile cleanly
├── 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 docs/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.
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.
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(notsemiSep) is used wherever trailing semicolons are expected. mapLitmust be tried beforesetLitinatom— both start with{andmapLitconsumes{ expr -> expr }whichsetLitwould misparse.framePatmust be wrapped intryin thepatalternatives — it is a reserved-word-prefixed parser that can fail after consuming input.- Port literals (
:22,:8080) in record field patterns usefieldLiteral, notliteral— the baseliteralparser does not handle:Nsyntax. FrameandFlowPatternare NOT inreservedNames; they appear as type names and must be accepted byidentifier.
Testing Conventions
- Test files use
{-# LANGUAGE OverloadedStrings #-}— required becauseA.StringexpectsData.Text.Text, notString. - IP address assertions use
LIP IPv4 n/LIP IPv6 n, not the oldLIPv4 (a,b,c,d)tuple constructors. - Priority assertions use
Priority ndirectly, e.g.Priority 0,Priority (-100). - All parse tests must compile and pass before any PR is merged.
Boundaries
✅ Safe to do without asking
- Read any file, list directories
- Run
cabal build,cabal test,cabal run fwlc - Edit
src/,test/,examples/,docs/ - 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/router.fwlin ways that change its semantics
🚫 Never
- Add semantic value names (
Allow,Drop,Log, etc.) toreservedNames - Break the
cabal testsuite - Emit nftables
"prio"as a string — it must always be an integer