# Setting up a coding agent for a C project

> Source: https://learn-c.net/ai/agent-setup/
> Part of Learn C, free to read.

Everything in this setup exists to answer one question: **when the agent writes something wrong, what tells us, and how fast?**

In Python a wrong program raises. In Go it usually fails to compile. In C it runs, produces the right answer for six months, and then corrupts the heap. So the setup here is more elaborate than elsewhere, and it is worth every minute.

## 1. Make the sanitized build the default

```makefile Makefile
CC     ?= clang
SRC    := $(wildcard src/*.c)
WARN   := -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-compare \
          -Wformat=2 -Wformat-security -Wnull-dereference -Wstrict-prototypes
HARDEN := -D_FORTIFY_SOURCE=3 -fstack-protector-strong
SAN    := -fsanitize=address,undefined -fno-omit-frame-pointer

.PHONY: debug release test check clean

debug:   CFLAGS  = -g -O1 $(WARN) $(HARDEN) $(SAN)
debug:   LDFLAGS = -fsanitize=address,undefined
debug:   app

release: CFLAGS  = -O2 -DNDEBUG $(WARN) $(HARDEN)
release: app

app: $(SRC)
	$(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS)

test: debug
	ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 \
	UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
	./app --test

check: test
	clang-tidy $(SRC) -- $(WARN)
	cppcheck --enable=all --error-exitcode=1 --inline-suppr src/
```

`make debug` is what everybody — human and agent — runs. The 2x slowdown is irrelevant; the precise stack trace at the moment of the bug is everything. See [the C failure modes](/review/failure-modes/) for exactly which bugs each sanitizer catches.

## 2. Give the tools a compilation database

```bash
bear -- make debug        # writes compile_commands.json
```

Without it, `clangd` and `clang-tidy` guess at your include paths and produce noise. With it, the agent's editor integration actually resolves your headers, which materially improves what it writes.

Commit it, or generate it in `make install`.

## 3. A test binary that runs in under five seconds

The loop matters as much here as anywhere. Something the agent can run after every edit:

```c test/main.c
#include "unity.h"

void test_parse_rejects_truncated_input(void) {
    packet_t p;
    TEST_ASSERT_EQUAL(PARSE_TRUNCATED, parse(&p, (uint8_t[]){0x01}, 1));
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_parse_rejects_truncated_input);
    return UNITY_END();
}
```

Unity or Criterion — the choice matters much less than the existence of `make test`.

## 4. A fuzz target for anything parsing bytes

Fifteen lines, and it is the highest-value test you will write for a parser.

```c fuzz/fuzz_parse.c
#include <stddef.h>
#include <stdint.h>
int LLVMFuzzerTestOneInput(const uint8_t *d, size_t n) { parse_packet(d, n); return 0; }
```

```makefile
fuzz:
	clang -g -O1 -fsanitize=fuzzer,address,undefined \
	  fuzz/fuzz_parse.c src/parser.c -o fuzz_parse
	./fuzz_parse -max_total_time=120 corpus/
```

Ask the agent to add a fuzz target in the same commit as any new parser. Two minutes of fuzzing finds inputs no reviewer would think to try.

## 5. Permissions

```json .claude/settings.json
{
  "permissions": {
    "allow": [
      "Bash(make debug)", "Bash(make test)", "Bash(make check)",
      "Bash(clang-tidy:*)", "Bash(cppcheck:*)",
      "Bash(git status)", "Bash(git diff:*)"
    ],
    "ask": ["Bash(make fuzz)", "Bash(git push:*)"],
    "deny": ["Bash(rm -rf:*)", "Bash(curl:*)", "Read(./.env)"]
  }
}
```

Note that `./app` is not on the allowlist. In C, running the program the agent just wrote is a meaningfully different act from running it in a memory-safe language — and if you are going to auto-allow it, do it in a container.

## 6. AGENTS.md

```markdown AGENTS.md
C17, clang. Sanitizers are on in the debug build. Debug is the default.

## Commands
- Build:  `make debug`
- Test:   `make test`   <- must pass, sanitizers clean
- All:    `make check`  (test + clang-tidy + cppcheck)
- Fuzz:   `make fuzz`

## Non-negotiable
- Check every allocation before use.
- calloc(n, size), never malloc(n * size).
- snprintf only. Never sprintf, strcpy, strcat, gets.
- size_t for sizes and indices, never int.
- One cleanup path per function: `goto cleanup`, one free per allocation.
- Never return a pointer to a local.
- Signed overflow is UB: use __builtin_add_overflow and friends.
- Any new parser of external input gets a fuzz target in the same commit.

## Landmines
- src/proto/ is wire-format code. Changing struct layout breaks the daemon.
- Do not add dependencies. This builds with a C compiler and nothing else.
```

That last line is worth more in C than in any other language: dependency management is manual, so the default answer to "should we add a library" is no, and saying so once saves a recurring argument.

The full file, with the reasoning behind each memory rule, is in [writing an AGENTS.md for C](/ai/agents-md/).

:::promo jetbrains
:::

## What this costs

Agent sessions are billed, and the bill is driven by context size more than by how much you ask for. The levers — prompt caching, pruning unused MCP servers, starting a fresh session when the task changes — are in [what tokens actually cost](https://codelearningdojo.com/token-economics/).

## The checklist

```text
[ ] make debug is sanitized and is what everyone runs
[ ] -Wall -Wextra -Wconversion clean on new files
[ ] compile_commands.json generated
[ ] make test runs in under 5 seconds
[ ] a fuzz target for every parser of untrusted input
[ ] running the built binary is confirmed, or containerised
[ ] AGENTS.md states the memory rules explicitly
```

## Common questions

### Is it reasonable to write C with an agent at all?

With this setup, yes. Models know C idioms well and generate competent code; the risk is that the residual errors are memory-safety bugs rather than wrong answers. Sanitizers plus static analysis plus fuzzing shift that risk enough to make the trade reasonable. Without them it is not.

### Should the agent be allowed to run the binary it just built?

Under sanitizers, in a container or a VM, yes. Directly on your machine with no isolation, think about it — a buffer overflow in freshly written code does whatever it does, and "it was only a test run" is how bad afternoons start.

### Why not just use Rust?

If you have the choice, that is a reasonable answer, and it gets more reasonable as generated code volume rises. Most C exists because of a constraint — an existing codebase, a platform, a kernel — and for that code, this setup is what makes the work survivable.
