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.

~/.claude/skills/c-systems/SKILL.md

--- name: c-systems description: Writes and hardens C (C11/C17/C23) for systems work with explicit buffer capacities, checked allocation and integer overflow, single-exit… license: MIT ---

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

  1. Identify the standard and compilers: -std= in the build, CI images, embedded toolchains. GCC 15 defaults to gnu23; older code may break on bool, true, nullptr or unprototyped f() now meaning f(void). Pin -std=c17 or -std=c23 explicitly.
  2. Build once with the warning set below and record the count; fix real bugs before style.
  3. Run the hazard scan for a quick map of risky calls: python3 scripts/c_hazards.py src/ (add --warn-only to report without failing).
  4. 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
  • -Werror in 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_SOURCE needs optimization (-O1+) to do anything.
  • MSVC: /W4 /sdl /guard:cf /analyze and /fsanitize=address.

Buffers and strings

TaskUseNever
Copy or format into a fixed buffersnprintf(buf, sizeof buf, "%s", src) and check the returnstrcpy, sprintf, strcat
Copy known-length bytesmemcpy(dst, src, n) after checking n <= capstrncpy as a “safe strcpy” (no terminator on truncation)
Read a linefgets(buf, sizeof buf, f) or POSIX getline(&line, &cap, f)gets, scanf("%s")
Parse an integerstrtol/strtoull with errno, endptr and range checksatoi, atol, sscanf("%d") without checks
Tokenizestrtok_r or a manual scan with lengthsstrtok in libraries or threads
Temp filemkstemptmpnam, 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_t for 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_mul from C23 <stdckdint.h> (GCC 14+, Clang 18+); on older compilers use __builtin_add_overflow / __builtin_mul_overflow.
  • sizeof *ptr instead of sizeof(struct foo) so the type cannot drift.
  • free(NULL) is a no-op; set pointers to NULL after 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 large n smashes 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/-1 at the top so the cleanup block is always safe.
  • Make every *_free function accept NULL.
  • __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.
  • const on every pointer parameter the function does not write through.

errno and error reporting

  • Read errno only immediately after a call that documents setting it and returned failure; set errno = 0 before calls like strtol whose 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, fclose and fflush on 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_t or C11 <stdatomic.h>; volatile is not a synchronization primitive.
  • Signal handlers may only call async-signal-safe functions and write volatile sig_atomic_t flags; no printf, malloc or locks inside.
  • Use sigaction, not signal.

Debugging and verification tools

ProblemTool
Overflow, use-after-free, double freeASan: -fsanitize=address (rebuild everything, including tests)
Signed overflow, bad shift, misaligned access, null derefUBSan: -fsanitize=undefined -fno-sanitize-recover=all
Leaks, uninitialized reads without rebuildingvalgrind --leak-check=full --error-exitcode=1 --track-origins=yes ./prog
Data racesTSan: -fsanitize=thread (separate build from ASan)
Static path analysisgcc -fanalyzer, scan-build make, clang-tidy, cppcheck --enable=warning,portability
Input-dependent crasheslibFuzzer with -fsanitize=fuzzer,address

Valgrind and ASan cannot run together; run Valgrind on a non-sanitized build.

Gotchas

  • char signedness is implementation-defined; cast to unsigned char before isdigit, toupper and table lookups.
  • strlen is 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 as free.
  • Struct padding: never memcmp structs or write them to disk/network directly; serialize field by field with explicit widths and endianness.
  • memset of a secret before free can be optimized away; use explicit_bzero or memset_explicit (C23).
  • Macros: parenthesize parameters and the whole body; wrap multi-statement macros in do { ... } while (0); prefer static inline functions.
  • 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 124 lines · 5.7 KB
#!/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.

esc