C Systems Programming skill for Claude Code
C that carries its buffer sizes and frees on every path. Claude writes C where every buffer travels with its capacity, size arithmetic is overflow-checked, and each function has one cleanup path that frees whatever it acquired. It designs opaque-handle library APIs with documented ownership, applies strict warning and hardening flags, scans for strcpy-class calls with a bundled script, and verifies with sanitizers and Valgrind.
- Name
c-systems- Size
- 224 lines · ≈2,450 tokens
- Files
- 2
- License
- MIT
- Updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and hardens C (C11/C17/C23) for systems work with explicit buffer capacities, checked allocation and integer overflow, single-exit cleanup, opaque-handle APIs with documented ownership, strict warning and hardening flags, and verification with ASan, UBSan, Valgrind, gcc -fanalyzer and a bundled unsafe-call scanner. Use when writing or reviewing C code, fixing segfaults, buffer overflows, leaks or double frees, designing a C library API, replacing strcpy/sprintf/atoi, or setting compiler flags for a Makefile, CMake or Meson build. Try asking
- This C daemon segfaults on long hostnames — find the overflow and fix the string handling
- Review this C library header: ownership, error codes and ABI stability
- Add hardening and sanitizer flags to our Makefile and fix whatever they find
Use it when
- Writing or reviewing C for daemons, libraries, CLIs or drivers
- A segfault, leak, double free or buffer overflow needs a root cause
- Designing a C API that other languages will bind to
- Choosing compiler warning, hardening and sanitizer flags
Skip it when
- The project compiles as C++ and can use RAII — use cpp-modern
- Microcontroller firmware with an RTOS — use freertos-tasks
- Building a fuzzing harness and corpus — use fuzz-testing
How to check the result
The build is warning-free with the strict flag set, the hazard scanner reports no errors, and tests pass under ASan+UBSan and Valgrind with leak checking and a non-zero error exit code.
The common mistake it prevents
Writing p = realloc(p, n) or overflow checks like if (a + b < a), which leak on failure or get deleted by the optimizer because signed overflow is undefined.
Done well means: every buffer travels with its capacity, every allocation and size computation is checked, every acquired resource is released on every path, the API documents who frees what, and the code builds warning-free with hardening flags and runs clean under ASan+UBSan and Valgrind.
Before changing anything
- Identify the standard and compilers:
-std=in the build, CI images, embedded toolchains. GCC 15 defaults tognu23; older code may break onbool,true,nullptror unprototypedf()now meaningf(void). Pin-std=c17or-std=c23explicitly. - Build once with the warning set below and record the count; fix real bugs before style.
- Run the hazard scan for a quick map of risky calls:
python3 scripts/c_hazards.py src/(add--warn-onlyto report without failing). - Run the tests under sanitizers to get a baseline before refactoring.
Compiler flags
CSTD = -std=c17
WARN = -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion \
-Wstrict-prototypes -Wmissing-prototypes -Wformat=2 -Wformat-truncation \
-Wnull-dereference -Wcast-qual -Wvla -Wimplicit-fallthrough -Wundef
HARDEN = -O2 -D_FORTIFY_SOURCE=3 -fstack-protector-strong -fstack-clash-protection \
-fPIE
LDHARDEN = -pie -Wl,-z,relro,-z,now
DEBUG = -O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined -fno-sanitize-recover=all
-Werrorin CI only, so new compiler versions do not break user builds.- GCC 14+ already turns implicit function declarations, implicit int and
int-to-pointer conversions into errors; on older compilers add
-Werror=implicit-function-declaration -Werror=incompatible-pointer-types. - x86-64 adds
-fcf-protection=full; AArch64 adds-mbranch-protection=standard. _FORTIFY_SOURCEneeds optimization (-O1+) to do anything.- MSVC:
/W4 /sdl /guard:cf /analyzeand/fsanitize=address.
Buffers and strings
| Task | Use | Never |
|---|---|---|
| Copy or format into a fixed buffer | snprintf(buf, sizeof buf, "%s", src) and check the return | strcpy, sprintf, strcat |
| Copy known-length bytes | memcpy(dst, src, n) after checking n <= cap | strncpy as a “safe strcpy” (no terminator on truncation) |
| Read a line | fgets(buf, sizeof buf, f) or POSIX getline(&line, &cap, f) | gets, scanf("%s") |
| Parse an integer | strtol/strtoull with errno, endptr and range checks | atoi, atol, sscanf("%d") without checks |
| Tokenize | strtok_r or a manual scan with lengths | strtok in libraries or threads |
| Temp file | mkstemp | tmpnam, mktemp |
/* Returns 0 on success, -1 if truncated. */
static int copy_str(char *dst, size_t cap, const char *src) {
int n = snprintf(dst, cap, "%s", src);
return (n < 0 || (size_t)n >= cap) ? -1 : 0;
}
static int parse_port(const char *s, uint16_t *out) {
char *end;
errno = 0;
long v = strtol(s, &end, 10);
if (errno != 0 || end == s || *end != '\0' || v < 1 || v > 65535) return -1;
*out = (uint16_t)v;
return 0;
}
- Pass
(ptr, len)or(ptr, cap)pairs; prefer a struct (struct buf { uint8_t *p; size_t len, cap; };) once the pair travels through more than one function. - Use
size_tfor sizes and indices; compare signed with unsigned only after checking the signed value is non-negative. sizeof array / sizeof array[0]works only on real arrays, not on pointer parameters. Wrap it in a macro and never use it inside a function that received a pointer.
Allocation and arithmetic
/* n * size overflow-checked: calloc does it for you */
struct item *items = calloc(count, sizeof *items);
if (!items) return -ENOMEM;
/* growing: never assign realloc's result straight back */
size_t new_cap;
if (ckd_mul(&new_cap, cap, 2)) return -EOVERFLOW; /* C23 <stdckdint.h> */
void *tmp = realloc(buf, new_cap * sizeof *buf); /* check this product too */
if (!tmp) return -ENOMEM; /* buf still valid, still owned */
buf = tmp; cap = new_cap;
ckd_add/ckd_sub/ckd_mulfrom C23<stdckdint.h>(GCC 14+, Clang 18+); on older compilers use__builtin_add_overflow/__builtin_mul_overflow.sizeof *ptrinstead ofsizeof(struct foo)so the type cannot drift.free(NULL)is a no-op; set pointers toNULLafter free when the variable outlives the call.- Signed overflow is undefined behavior; the optimizer deletes checks written as
if (a + b < a). Check before the operation or use the checked builtins. - No VLAs for sizes that come from input (
-Wvla); a largensmashes the stack.
Cleanup: one exit path
int load_config(const char *path, struct config **out) {
int rc = -1;
FILE *f = NULL;
char *text = NULL;
struct config *cfg = NULL;
f = fopen(path, "rb");
if (!f) goto done;
text = read_all(f, MAX_CONFIG_BYTES);
if (!text) goto done;
cfg = config_parse(text);
if (!cfg) goto done;
*out = cfg;
cfg = NULL; /* ownership transferred */
rc = 0;
done:
config_free(cfg); /* safe on NULL */
free(text);
if (f) fclose(f);
return rc;
}
- Initialize every resource variable to
NULL/-1at the top so the cleanup block is always safe. - Make every
*_freefunction acceptNULL. __attribute__((cleanup(fn)))(GCC/Clang) is fine inside a codebase that already uses it; do not introduce it into portable libraries.
Designing a C API
/* mylib.h */
typedef struct mylib_ctx mylib_ctx; /* opaque: fields in .c only */
typedef enum { MYLIB_OK = 0, MYLIB_ENOMEM, MYLIB_EINVAL, MYLIB_EIO } mylib_status;
mylib_status mylib_open(const char *path, mylib_ctx **out); /* caller frees with mylib_close */
void mylib_close(mylib_ctx *ctx); /* NULL-safe */
mylib_status mylib_read(mylib_ctx *ctx, uint8_t *buf, size_t cap, size_t *nread);
const char *mylib_strerror(mylib_status s); /* static string, do not free */
- Opaque structs keep ABI stable; expose versioned option structs
(
struct mylib_opts { uint32_t size; ... }) when you must pass many settings. - Return a status; deliver results through out-parameters. Do not overload return values as “pointer or NULL” when the caller needs to know why it failed.
- Document ownership on every pointer: borrowed for the call, borrowed until
close, or transferred to the caller. - No hidden global state; take a context. If globals are unavoidable, document thread safety and protect them.
- Prefix every exported symbol (
mylib_) and build with-fvisibility=hidden, exporting the public API explicitly. conston every pointer parameter the function does not write through.
errno and error reporting
- Read
errnoonly immediately after a call that documents setting it and returned failure; seterrno = 0before calls likestrtolwhose success value is ambiguous. - Library code returns status codes and never calls
exit()or prints to stderr on behalf of the caller. - Check return values of
close,fcloseandfflushon files you wrote; buffered write errors surface there. - Mark must-check functions
[[nodiscard]](C23) or__attribute__((warn_unused_result)).
Concurrency and signals
- Shared mutable state:
pthread_mutex_tor C11<stdatomic.h>;volatileis not a synchronization primitive. - Signal handlers may only call async-signal-safe functions and write
volatile sig_atomic_tflags; noprintf,mallocor locks inside. - Use
sigaction, notsignal.
Debugging and verification tools
| Problem | Tool |
|---|---|
| Overflow, use-after-free, double free | ASan: -fsanitize=address (rebuild everything, including tests) |
| Signed overflow, bad shift, misaligned access, null deref | UBSan: -fsanitize=undefined -fno-sanitize-recover=all |
| Leaks, uninitialized reads without rebuilding | valgrind --leak-check=full --error-exitcode=1 --track-origins=yes ./prog |
| Data races | TSan: -fsanitize=thread (separate build from ASan) |
| Static path analysis | gcc -fanalyzer, scan-build make, clang-tidy, cppcheck --enable=warning,portability |
| Input-dependent crashes | libFuzzer with -fsanitize=fuzzer,address |
Valgrind and ASan cannot run together; run Valgrind on a non-sanitized build.
Gotchas
charsignedness is implementation-defined; cast tounsigned charbeforeisdigit,toupperand table lookups.strlenis O(n); calling it in a loop condition makes the loop O(n^2).realloc(p, 0)behavior is implementation-defined and undefined in C23; never use it asfree.- Struct padding: never
memcmpstructs or write them to disk/network directly; serialize field by field with explicit widths and endianness. memsetof a secret before free can be optimized away; useexplicit_bzeroormemset_explicit(C23).- Macros: parenthesize parameters and the whole body; wrap multi-statement macros in
do { ... } while (0); preferstatic inlinefunctions. printf("%d", size_t_value)is wrong; use%zu, and<inttypes.h>macros (PRIu64) for fixed-width types.
Verification
- [ ] Build clean with WARN flags (+ -Werror in CI) on every target compiler
- [ ] python3 scripts/c_hazards.py src/ reports no errors (justify remaining warnings)
- [ ] Tests pass under ASan+UBSan (DEBUG flags) with zero reports
- [ ] valgrind --leak-check=full --error-exitcode=1 on the test binary: no leaks
- [ ] gcc -fanalyzer or scan-build shows no new findings on changed files
- [ ] Every new pointer parameter or return documents ownership in the header
If a sanitizer fires, fix the first report only, rebuild, and rerun; later reports are often consequences of the first.
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/c_hazards.py
#!/usr/bin/env python3
"""Flag hazardous C library calls and patterns in .c/.h files.
Usage:
python3 c_hazards.py <file-or-dir> [...] [--warn-only]
Comments and string literals are blanked before matching, so line and column numbers
stay correct and text inside strings does not trigger findings. Exit code: 1 if any
"error" finding exists (unless --warn-only), 2 on usage error, 0 otherwise.
This is a fast triage pass, not a replacement for compiler warnings, -fanalyzer,
clang-tidy or sanitizers.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# (rule id, severity, regex, advice)
RULES: list[tuple[str, str, re.Pattern[str], str]] = [
("gets", "error", re.compile(r"\bgets\s*\("), "removed in C11; use fgets(buf, sizeof buf, stream)"),
("strcpy", "error", re.compile(r"\bstrcpy\s*\("), "unbounded copy; use snprintf or memcpy with a checked length"),
("strcat", "error", re.compile(r"\bstrcat\s*\("), "unbounded append; track length and use memcpy/snprintf"),
("sprintf", "error", re.compile(r"\bv?sprintf\s*\("), "unbounded format; use snprintf and check the return for truncation"),
("scanf-s", "error", re.compile(r"\b(?:f|s)?scanf\s*\([^;]*%s"), "%s without a width overflows; use %63s style widths or fgets"),
("realloc-self", "error", re.compile(r"\b(\w+)\s*=\s*realloc\s*\(\s*\1\s*,"), "leaks the old block on failure; assign to a temporary first"),
("printf-nonliteral", "error", re.compile(r"\b(?:f?printf|syslog)\s*\(\s*(?:stderr\s*,\s*|stdout\s*,\s*|LOG_\w+\s*,\s*)?[A-Za-z_][\w\[\].>-]*\s*\)"), "non-literal format string; use \"%s\", var"),
("strncpy", "warn", re.compile(r"\bstrncpy\s*\("), "does not NUL-terminate on truncation; prefer snprintf or explicit memcpy + terminator"),
("ato", "warn", re.compile(r"\bato(?:i|l|ll|f)\s*\("), "no error reporting; use strtol/strtoul/strtod with errno and endptr checks"),
("strtok", "warn", re.compile(r"\bstrtok\s*\("), "not reentrant; use strtok_r (POSIX) or a manual tokenizer"),
("alloca", "warn", re.compile(r"\balloca\s*\("), "unbounded stack allocation; use malloc or a fixed-size buffer"),
("malloc-mul", "warn", re.compile(r"\bmalloc\s*\([^;]*\*[^;]*\)"), "size multiplication can overflow; use calloc(n, size) or check with ckd_mul/__builtin_mul_overflow"),
("system", "warn", re.compile(r"\b(?:system|popen)\s*\("), "shell invocation; use fork/exec or posix_spawn with an argv array"),
("rand", "warn", re.compile(r"\brand\s*\(\s*\)"), "not suitable for security; use getrandom/arc4random or a seeded PRNG for tests"),
("tmpnam", "error", re.compile(r"\b(?:tmpnam|tempnam|mktemp)\s*\("), "race-prone temp file names; use mkstemp"),
]
EXTS = {".c", ".h"}
def blank_comments_and_strings(src: str) -> str:
"""Replace comment and literal contents with spaces, preserving newlines."""
out = []
i, n = 0, len(src)
state = "code"
quote = ""
while i < n:
c = src[i]
nxt = src[i + 1] if i + 1 < n else ""
if state == "code":
if c == "/" and nxt == "/":
state = "line"; out.append(" "); i += 2; continue
if c == "/" and nxt == "*":
state = "block"; out.append(" "); i += 2; continue
if c in "\"'":
state = "str"; quote = c; out.append(c); i += 1; continue
out.append(c); i += 1; continue
if state == "line":
if c == "\n":
state = "code"; out.append("\n")
else:
out.append(" ")
i += 1; continue
if state == "block":
if c == "*" and nxt == "/":
state = "code"; out.append(" "); i += 2; continue
out.append("\n" if c == "\n" else " "); i += 1; continue
# inside a string or char literal: keep %s visible for the scanf rule
if c == "\\" and i + 1 < n:
out.append(" "); i += 2; continue
if c == quote:
state = "code"; out.append(c); i += 1; continue
if c == "\n":
state = "code"; out.append("\n"); i += 1; continue
out.append("%" if c == "%" else ("s" if c == "s" and out and out[-1] == "%" else " "))
i += 1
return "".join(out)
def scan(path: Path) -> list[tuple[str, int, int, str, str, str]]:
text = path.read_text(encoding="utf-8", errors="replace")
clean = blank_comments_and_strings(text)
findings = []
for lineno, line in enumerate(clean.splitlines(), start=1):
for rule, sev, rx, advice in RULES:
for m in rx.finditer(line):
findings.append((str(path), lineno, m.start() + 1, sev, rule, advice))
return findings
def iter_files(args: list[str]):
for a in args:
p = Path(a)
if p.is_dir():
yield from sorted(f for f in p.rglob("*") if f.suffix in EXTS and f.is_file())
elif p.is_file():
yield p
else:
print(f"c_hazards: no such file or directory: {a}", file=sys.stderr)
sys.exit(2)
def main() -> int:
argv = sys.argv[1:]
warn_only = "--warn-only" in argv
paths = [a for a in argv if a != "--warn-only"]
if not paths:
print(__doc__.strip(), file=sys.stderr)
return 2
all_findings = []
files = 0
for f in iter_files(paths):
files += 1
all_findings.extend(scan(f))
for path, line, col, sev, rule, advice in all_findings:
print(f"{path}:{line}:{col}: {sev}: [{rule}] {advice}")
errors = sum(1 for f in all_findings if f[3] == "error")
warns = len(all_findings) - errors
print(f"\nScanned {files} file(s): {errors} error(s), {warns} warning(s)")
return 1 if errors and not warn_only else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.