
C Bounds Safety
- 248 installs
- 263 repo stars
- Updated June 9, 2026
- superagents-lab/xcode27-skills
Helps with ai & agent building tasks.
About
c-bounds-safety is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- c-bounds-safety
- AI & Agent Building
- AI-coding skill
C Bounds Safety by the numbers
- 248 all-time installs (skills.sh)
- +22 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,553 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/superagents-lab/xcode27-skills --skill c-bounds-safetyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 248 |
|---|---|
| repo stars | ★ 263 |
| Last updated | June 9, 2026 |
| Repository | superagents-lab/xcode27-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
How to Use This Skill
When helping with -fbounds-safety adoption or code changes, ask clarifying questions about the user's codebase and goals before suggesting changes. For complex tasks involving multiple files or non-trivial annotation decisions, use plan mode to propose an approach before implementing.
-fbounds-safety Language Extension
-fbounds-safety is a C language extension that prevents out-of-bounds memory access by enforcing bounds safety at the language level. It inserts automatic bounds checks at runtime, rejects unsafe pointer operations at compile time, and requires programmers to provide bounds annotations so the compiler can guarantee safety. Out-of-bounds accesses become deterministic traps instead of exploitable vulnerabilities.
Detailed Documentation
Required reading before adoption work
You MUST have fully read the following three documents (via the Read tool) at the start of an adoption task, and re-read them via the Read tool before any source-modifying step in the adoption workflow unless their content is verifiably fresh in your active context:
- adoption-strategies.md — the workflow for adopting
-fbounds-safetyin an existing C project (full and header-only modes). - language-overview.md — the language reference for
-fbounds-safety: pointer kinds, annotations, and the rules that govern them. - common-patterns-and-pitfalls.md — recipes and anti-patterns encountered during real-world adoption.
Other references (read on demand)
For compiler flags, Xcode build settings, soft trap mode, and ptrcheck.h configuration, read build-settings.md.
For debugging bounds violations at runtime — trap behavior, LLDB commands, wide pointer inspection, watchpoints, crash log analysis, and soft trap debugging, read runtime-debugging.md.
Adoption Strategies for -fbounds-safety
This guide walks through the process of adopting -fbounds-safety in an existing C project.
-fbounds-safety maintains ABI compatibility, so you can adopt it without breaking clients that don't use it. Incremental adoption is supported — you can secure your code file by file over multiple releases.
Before asking the user anything or starting any planning, present the following message to them verbatim:
>
> Preparing to help you adopt -fbounds-safety, which is a C language extension that enforces bounds safety through compile-time and runtime checks.
>
> 1. I'll ask some questions to identify the kind of adoption you want to do.
> 2. I'll analyze your code and write a plan to perform the adoption.
> 3. Once you confirm the plan, I'll perform the adoption in multiple steps, stopping at relevant points to give you a chance to review the changes before I commit them.
Before advising on adoption, ask the user whether they want full adoption or header-only adoption, then provide guidance for the chosen approach.
Always make a plan when applying this skill because changes are rarely trivial and the developer needs to understand the process
Choosing an Adoption Approach
There are two approaches to adopting -fbounds-safety:
- Full adoption: Annotate headers AND enable
-fbounds-safetyin implementation files. Provides complete bounds safety enforcement — the compiler inserts runtime bounds checks in your code and rejects unsafe operations at compile time.
- Header-only adoption: Only annotate public headers. The implementation remains unchanged and is not compiled with
-fbounds-safety. Lightweight alternative that benefits clients adopting-fbounds-safetywithout any runtime cost or code changes to your library's implementation. If there are no headers do not suggest this approach.
Full Adoption
Typical source code changes
Enabling -fbounds-safety implicitly adds bound annotations (e.g. __single) on pointer/array type declarations. Each bound annotation has different restrictions on how they can be used and these restrictions are enforced by a mixture of compile time and runtime checks. The compile time checks appear as compiler diagnostics. All errors will need to be fixed and warnings should be addressed if possible. Fixing these diagnostics typically is a mixture of
1. Explicitly using different bounds attributes from the ones that are implicitly added.
In many cases, adoption involves annotating pointers passed as parameters or stored in structures:
// BEFORE
void take_elements(const element_t *elements, size_t count);
// AFTER
void take_elements(const element_t *__counted_by(count) elements, size_t count);Avoid ABI-incompatible annotations (__indexable or __bidi_indexable) on consumer-facing APIs. Also avoid use of __unsafe_indexable which is unsafe and defeats the purpose of using -fbounds-safety in the first place.
Knowing which attributes to use typically requires looking at how the type is used. For example if annotating a function, looking at use sites and the implementation of that function may provide clues on what the bounds are and thus the appropriate annotation to add to that function
2. Adapting implementation code to work with the compile time restrictions added by using bounds attributes.
e.g.:
// BEFORE
int find_zero(int *__counted_by(count) elements, size_t count) {
int idx = -1;
while (idx < count && *elements != 0) {
// error: assignment to 'int *__single __counted_by(count)' 'elements' requires corresponding assignment to 'count'
++elements;
++idx;
}
return idx;
}
// AFTER
int find_zero(int *__counted_by(count) elements, size_t count) {
int idx = -1;
size_t original_count = count;
while (idx < original_count && *elements != 0) {
++elements;
--count;
++idx;
}
return idx;
}3. Propagating bounds annotation choices
As bounds annotations on API surfaces are changed this potentially impacts all use sites of them leading to different compiler diagnostics. This requires an iterative process of changing annotations, recompiling, looking at the diagnostics and deciding what to fix, fixing, and repeating until the source file can be compiled without errors.
4. Refactoring code such that the use of unsafe constructs happens as few places as possible.
When a project adopting -fbounds-safety needs to interact with code that hasn't adopted -fbounds-safety typically that means ingesting __unsafe_indexable pointers. Ideally we do not want to propagate that __unsafe_indexable pointer through out the codebase. Instead there should be a centralized place(s) where __unsafe_indexable pointers are consumed and then forged into a safe pointer type (i.e. __unsafe_forge_bidi_indexable) which is then propagated through the codebase. That way the majority of the project works with safe pointer types and the sources of unsafe pointers is very small and easier to audit.
Adoption strategy
Tracking adoption progress
Adoption has many sub-steps across many files. Use TaskCreate at three moments so no sub-step is forgotten while keeping the active task list focused.
Moment A — before any file is modified. Create one task for:
Confirm approach with the user(full vs header-only)Confirm how to run tests with the user(full adoption only — capture how to run the tests (e.g. shell command, unit tests, etc.). If the user declines tests at this point, follow the explicit-confirmation procedure in §3 now rather than deferring it to §3 entry, so the no-tests decision is made deliberately at the earliest opportunity.)- Each top-level step below: 0, 1, 2, 4 (full adoption only), 5.1 (umbrella checkpoint only — full adoption only — see note below), 6 (full adoption only)
- A trigger task
Create per-file adoption tasks— its body creates Moment B's tasks once the adoption order is known. It must exist so per-file task creation isn't forgotten.
Step 5.x umbrella checkpoint tasks are placeholders at adoption start; they apply only to full adoption (header-only adoption has its own §3 Safe Wrapper retrofits but does not reach full adoption's §3 onwards). Per-item tasks accumulate underneath each umbrella as earlier phases (e.g. Phase 1) make decisions; their addBlocks wires them to the corresponding umbrella, which is itself wired into the per-file → 4 → 5.x → 6 chain (see Moment B).
Moment B — body of the `Create per-file adoption tasks` task, run immediately after step 0 completes. For every implementation file in adoption order that does not already have a per-file task, create one named Adopt -fbounds-safety in <file>. (The §3 Skipping a file's enablement procedure already creates a per-file task for any file flagged upfront for skip; don't re-create those.) All file-level tasks must be created at once so the full adoption scope is visible, but sub-tasks are deferred to Moment C — this keeps the pending-task list short and lets sub-step applicability be decided per file at execution time.
After creating every file-level task, wire the dependency chain files → 4 → each 5.x umbrella → 6 by calling TaskUpdate with the appropriate addBlockedBy:
- The step 4 target-level task gets
addBlockedBylisting every file-level task (so target-level enablement waits for all per-file adoption). - Each step 5.x umbrella checkpoint task gets
addBlockedBy [<step 4 task ID>](so post-target refinements wait for target-level enablement). - The step 6 completion-milestone task gets
addBlockedBylisting every step 5.x umbrella (so the milestone surfaces only after the post-target batches land).
If any file is later skipped via §3 Skipping a file's enablement, no rewiring is needed; §5 and subsequent tasks unblock automatically.
Moment C — first action when picking up any `Adopt -fbounds-safety in <file>` task. Before modifying the file, TaskCreate sub-tasks for it mirroring sub-steps 3.1, 3.2, 3.3 (omit if the user did not provide a way to run the tests), 3.4, 3.5a, 3.5b. Only mark the file-level task in_progress after its sub-tasks exist.
Rules for marking tasks complete:
- Only mark a task
completedwhen that specific sub-step is done. - A file-level task is complete only when all 6 of its sub-tasks are complete.
- If a sub-task legitimately does not apply (e.g. the file has no runtime tests to exercise it), mark it complete with a one-line note explaining why. Do not skip silently.
Commit hygiene at review stops
Every commit during adoption is preceded by a stop-and-review step. During that stop the user is explicitly invited to inspect and modify the changes. Their edits must end up in a commit — they must not be silently left in the working tree or dropped. Follow this procedure at every commit point in this guide:
1. Before staging anything, run git status and git diff to enumerate all working-tree changes. This includes both Claude's edits and any further edits the user made while the stop was open. Do not assume the working tree contains only what Claude wrote. 2. Classify each modified or new file as source-code (.c, .h, validation files) or build-system (Xcode project.pbxproj, CMakeLists, Makefiles, any per-file flag entry). 3. Check the result against the commit's declared scope (stated at each commit site below — e.g. "source-code only", "build-system only", or "headers + validation file"):
- If every changed file fits the scope, stage exactly those files (Claude's + user's) and commit.
- If the user's edits span kinds that don't all fit the scope — for example, source-code edits appearing during a build-system-only commit — stop and ask the user how to split them: which go into the current commit, which should be deferred to the next one, and which (if any) should be dropped. Apply their answer, then commit.
4. Never git add -A or git add . blindly — always stage by explicit filename after classification, so unrelated working-tree changes (e.g. unrelated .DS_Store, scratch files) are not pulled in. 5. Do not propose git commit --amend to fold user edits into a previously-made commit unless the user explicitly asks for it.
This procedure is referenced from §2, §3 step 5a, §3 step 5b, and §5.x's verify-stop-and-commit body below.
0. Code Research
Order of adoption
If the user has not stated in which target they want to do adoption and it cannot be inferred ask them to clarify which target.
Once the target is known if it contains more than one .c source file we need to decide the order implementation files will adopt -fbounds-safety. Some analysis of the code can guide this
use a sub-agent to do this analysis and return an ordered list of implementation files
- Computing a callgraph for functions in public headers can be used to guide implementation file order. Typically source files that implement public functions should adopt -fbounds-safety first as they may provide bounds information that needs to be propagated throughout the code base. Traversing the call graph starting at the roots can guide implementation file order as each node has an implementation file associated with it. If we have a -> b, and a and b are implemented in different source files then this is a hint that the implementation file a should adopt -fbounds-safety before b.
- The same as above can be done for private headers
If the user already knows a particular .c file is unadoptable in this pass (e.g. a known compiler crash, or they want to defer it), invoke the §3 Skipping a file's enablement procedure the moment the user declares the skip.
1. Headers First
Before doing this step, re-read `language-overview.md` and `common-patterns-and-pitfalls.md` in full via the Read tool.
Annotate public headers with bounds annotations on function parameters, return types, struct fields, and globals. Adding -fbounds-safety annotations to a header signals that the header has adopted bounds safety; clients compiled with -fbounds-safety will see the annotations and benefit from compile-time and call-site checks.
- (Full adoption only) Modify headers before implementation files — implementation files will need all header definitions to have adopted
-fbounds-safetyfirst. - Clients benefit from annotated interfaces even when the implementation doesn't enable
-fbounds-safety. - Unannotated interfaces result in all pointers being
__unsafe_indexable, which is cumbersome for-fbounds-safetyclients.
Example annotations:
// C standard library style:
void *memcpy(void *__sized_by(n) dst, const void *__sized_by(n) src, size_t n);
// Custom API:
int process_buffer(const uint8_t *__counted_by(len) data, size_t len);After adopting -fbounds-safety in a public header, add this directive at the start:
#include <ptrcheck.h>
__ptrcheck_abi_assume_single()This tells the compiler that ABI-visible pointers (except const char*) in this header should be treated as __single (not __unsafe_indexable, which is the default for SDK headers). __ptrcheck_abi_assume_single also only affects the current header, it does not affect the attributes in subsequently included headers.
Capturing deferred Safe Wrapper retrofits
When choosing __unsafe_indexable on a public-API function parameter or return, create a per-item Safe Wrapper task immediately. Capture happens at the moment of decision because the rationale is fresh; execution defers to step 5.1 in full adoption (see 5. Post-target-level refinements) or to step 3 in header-only adoption (see 3. Safe Wrapper retrofits (if any captured)).
Setup: the upfront task-creation step creates the Safe Wrapper umbrella. Its name and wiring depend on the adoption mode:
- Full adoption (Moment A): umbrella is
5.1 Commit Safe Wrapper batch,addBlockedBy [<step 4 task ID>],addBlocks [<step 6 task ID>]. - Header-only adoption (Header-Only Adoption's
Tracking adoption progresssubsection): umbrella is3b. Commit Safe Wrapper batch,addBlockedBy [<3a task ID>],addBlocks [<milestone task ID>].
For each __unsafe_indexable decision on a public-API parameter or return:
1. Defensive umbrella check. Before creating the per-item task, confirm the Safe Wrapper umbrella exists. If not (e.g. the adoption was picked up mid-stream and the upfront task-creation step never ran for this session), create it now with the wiring for the current adoption mode (see Setup above). 2. Grep for the function's definition to identify the implementing .c file. (If the function is defined outside any file you're adopting, ask the user how to handle it.) 3. TaskCreate a task Add Safe Wrapper for <funcName> with a structured description like:
Apply the Safe Wrappers for Public APIs pattern.
- Function: <funcName>
- Header: <header path>
- Implementation file: <file>.c
- Original signature (with __unsafe_indexable):
<verbatim signature>
- Reason for __unsafe_indexable: <one line — e.g. "length-prefixed buffer; bound is buf[0]">
See [Safe Wrappers for Public APIs](common-patterns-and-pitfalls.md#safe-wrappers-for-public-apis) for the recipe.(The "do not commit between per-item tasks" instruction lives in §5's framing in full adoption and in §3's framing in header-only, not in each per-item description.) 4. TaskUpdate addBlockedBy so the wrapper task can't surface until its gating predecessor is done — [<step 4 task ID>] in full adoption; [<3a Confirm Safe Wrapper application task ID>] in header-only. 5. TaskUpdate addBlocks [<Safe Wrapper umbrella task ID>] so the umbrella checkpoint waits for this wrapper.
Do not put the wrapper list in the umbrella task's description — per-item tasks track per-item state and verification natively. The umbrella's description is just the verify-stop-and-commit body.
2. Create a Validation File
Create a single .c file that includes every adopted header and compile it with -fbounds-safety. This ensures headers are compliant even if your project doesn't yet fully use -fbounds-safety.
Compiling the validation file requires -fbounds-safety to be added as a per-file build flag on it.
After creating the validation file (and any header adjustments needed to make it compile), stop and ask the user to review before committing. In that message:
- State that header files have been modified to adopt -fbounds-safety and that a validation file has been added to ensure the changes parse when -fbounds-safety is on.
- State that on approval the new validation file and any header changes will be committed together.
- List the names of the modified header files and new validation file.
- Invite the user to inspect the changes, make any further changes they need, and approve when ready to commit.
On approval, commit the changes following the Commit hygiene at review stops procedure. The scope of this commit is header edits + the new validation file, committed together as a single commit — the 5a/5b source-vs-build split does not apply here.
If you are doing header-only adoption, stop here. Do not proceed to "3. Enable Per-File in Implementation" — that section is only for full adoption.
3. Enable Per-File in Implementation
Before doing this step, re-read `language-overview.md` and `common-patterns-and-pitfalls.md` in full via the Read tool.
Enable -fbounds-safety in implementation files one at a time. Use the order computed in "Order of adoption". If the compiler crashes at any point during this section, see Handling a compiler crash below before continuing.
Before starting this section, confirm with the user how to run the project's tests (this should already have been captured by the Confirm how to run tests task in Moment A — re-confirm if it was not). If the user cannot or will not provide a way to run the tests, stop and ask them, verbatim:>
> Performing -fbounds-safety adoption without providing tests to verify runtime behavior greatly increases the chance of adopted code containing reachable runtime traps due to failing bounds checks. Are you sure you want to proceed without providing tests?>
Wait for the user's explicit answer.
- If the user confirms they want to proceed without tests: skip sub-step 3 below ("Run the project's tests and fix any runtime traps") for every file in this section. The same skip applies to §5.1 step 2.
- If the user changes their mind and wants to provide tests: capture how to run the tests from them (e.g. shell command, unit tests, etc.), record it for use in sub-step 3 (and §5.1 step 2), and continue with sub-step 3 enabled.
1. Enable -fbounds-safety for a single C file by adding it as a per-file build flag. 2. Fix compilation errors (compiler diagnostics guide you on what annotations to add). Use -ferror-limit=0 to get unlimited diagnostics if you want to see all errors at once. 3. Run the project's tests and fix any runtime traps. See runtime-debugging.md. (Skip this sub-step if the user could not provide a way to run the tests — see the warning at the top of this section.) 4. Stop and ask the user to review the changes for this file before committing. Before summarizing what changed, communicate the following three things in this order:
1. Identify the file: state that the source-file changes under review are for <filename> (the actual file path). 2. Explain what will happen on approval: the changes will be committed in two steps — first, the source-code changes committed with -fbounds-safety switched off for this file; second, a build-system change that re-enables -fbounds-safety for this file. This split is done to make it easy to revert the enablement later without losing the source-code improvements. 3. Invite the user to inspect the changes, make any further changes they need, and approve when ready to commit.
Then summarize the actual changes (annotations added, refactors, any unsafe forges introduced). Wait for the user's explicit approval. If they request adjustments, apply them, re-run the project's tests, and ask again. Only proceed to step 5 once the user has explicitly approved. 5. Commit the work for this file as two separate commits. This structure is MANDATORY — do NOT combine into a single commit.
5a. Source-changes commit.
- Temporarily clear
-fbounds-safetyfrom this file's per-file build flags. - Verify the source still compiles without the flag.
- If it does not compile, make the minimum changes needed to compile cleanly with the flag off, then stop and tell the user explicitly: we stopped because additional source changes were needed since the file did not compile with `-fbounds-safety` disabled. Ask them to review the changes, make any necessary further changes, and continue when they approve. Apply any requested adjustments and re-verify the build before proceeding. When execution resumes, the Commit hygiene at review stops procedure applies to whatever the user touched during this sub-stop.
- Commit following the Commit hygiene at review stops procedure. Scope: source-code only (annotations, refactoring). Any build-system changes in the working tree are deferred to 5b — if the user's edits span both kinds, the shared procedure will stop and ask.
5b. Build-system commit.
- Re-add
-fbounds-safetyas a per-file build flag for this file. - Verify it still compiles.
- Commit following the Commit hygiene at review stops procedure. Scope: build-system only. If the user added source-code edits between 5a and now, the shared procedure will stop and ask how to handle them — do not silently bundle them into this commit.
Rationale: this separates source churn from the act of enabling the flag. If enablement has to be reverted later, only commit 5b is reverted — the source-code improvements from 5a remain. Collapsing into one commit loses this property.
6. Repeat the above until every file in the adoption order is either adopted or explicitly skipped via Skipping a file's enablement below.
Handling a compiler crash
If a build during sub-step 1 (per-file flag enablement) or sub-step 2 (fixing compilation errors) crashes the compiler, clang's stderr will include a PLEASE ATTACH THE FOLLOWING FILES TO THE BUG REPORT block listing .c (preprocessed source) and .sh (replay script) paths in $TMPDIR, plus a pointer to ~/Library/Logs/DiagnosticReports/clang_<...>.crash. That block is the cue to enter this procedure — don't keep chasing compile errors.
1. Gather a reproducer via a sub-agent. Spawn a sub-agent (Task tool, general-purpose) with these self-contained instructions:
- Extract the
.cand.shpaths from the crash output the parent provides. - Re-run the
.shscript and confirm it triggers the crash. If it does not, report that back — the crash may not be reliably reproducible. - Multi-arch handling: if the original build used multiple
-archoptions, clang reportsError generating preprocessed source(s) - cannot generate preprocessed source with multiple -arch optionsinstead of producing the.c/.sh. In that case, re-invoke the same compile command with each-archvalue individually until one (or more) crashes, gathering the reproducer per crashing arch. - Locate the matching crash log under
~/Library/Logs/DiagnosticReports/clang_<YYYY-MM-DD-HHMMSS>_<hostname>.crash— pick the one whose timestamp matches the crash. - Bundle the
.c,.sh, and.crashinto a single zip at<project-root>/<crashing-filename>-crash-reproducer.zip(one zip per crashing arch if multi-arch). - Report back: the zip path(s), which arch(es) reproduced, and any missing files.
The preprocessed .c and .sh are large (often >1 MB combined); using a sub-agent keeps that bulk out of the main conversation context.
2. Ask the user to file feedback using Feedback Assistant (non-blocking). Say something like:
"I gathered a crash reproducer at<zip-path>. Please file a feedback about this Clang-fbounds-safetycrash using Feedback Assistant — either the Feedback Assistant app or https://feedbackassistant.apple.com — and attach the archive. You can continue with the workflow before or after filing; let me know the Feedback ID if you do file, since I'll reference it in any workaround comment."
Then proceed immediately to Step 3 without waiting. If the user later supplies a Feedback ID, use it; otherwise the workaround comment in Step 5 falls back to referencing the local archive path.
3. Ask the user: skip or workaround? Say something like:
"How would you like to proceed with <file>?(a) Skip enablement for this file (uses the skip procedure below).
(b) Attempt to work around the crash with light source changes (a few locations, no medium-large refactors)."
Wait for the user's explicit answer.
4a. If skip: invoke the Skipping a file's enablement procedure with reason compiler crash (include the Feedback ID if the user supplied one). No further action needed in this sub-section.
4b. If workaround: try light source-level changes in the failing file. Common starting points (not exhaustive — pick what fits):
- Revert the most recent annotation that touched the crash site.
- Replace the offending annotation with
__unsafe_indexableat the specific declaration that triggers the crash. This loses bounds safety at that one site — capture it as a Safe Wrapper retrofit if it's on a public API. - Restructure the single expression or statement the crash points at to avoid the construct that triggers the crash.
Keep workarounds light. If avoiding the crash would require changing more than a handful of source locations, or any structural refactoring, stop and return to Step 3 to choose skip instead. Medium-large refactors are out of scope for this procedure; that workload belongs in a separately planned change.
5. (workaround only) Leave a discoverable comment at every workaround site. Each source location modified to dodge the crash gets a short comment that names what would have been written here without the crash, so a future reader can find it and restore the intended change once the compiler is fixed:
// WORKAROUND for clang -fbounds-safety crash.
// Intended: <one-line description of the annotation/change we wanted to make here, e.g. "__counted_by(len) on `buf` parameter">.
// See Feedback Assistant <FB-ID> (or <relative path to crash-reproducer zip>).The literal token WORKAROUND for clang -fbounds-safety crash must appear verbatim so the workarounds are grep-able across the codebase. The Intended: line briefly describes the change that would have landed here without the crash — keep it tight (one line) so it's useful but not laborious to write. Use the Feedback ID the user supplied; if none, reference the local archive path.
After a successful workaround, return to sub-step 2 to fix any remaining compilation errors and proceed normally through 3, 4, 5a/5b for this file. If a new crash surfaces during the same file's adoption, re-enter this procedure from Step 1.
Skipping a file's enablement
A .c file in the target may turn out not to be adoptable in this pass (e.g. the compiler crashes on it, or the user deliberately defers it). The user can request to skip enablement for that file at any point: upfront during §0 Order of adoption, or mid-stream while working through §3. Run this procedure the moment the skip is declared. If the trigger is a compiler crash, first run Handling a compiler crash; that procedure invokes this one on its skip branch. A target with any skipped file is referred to elsewhere in this guide as being under partial-target adoption.
1. Confirm with the user. Before acting, restate that proceeding with one or more files skipped has these consequences:
- §4 [Switch to target-level enablement](#4-switch-to-target-level-enablement) is bypassed. Per-file
-fbounds-safetyflags stay on the adopted files indefinitely; the target does not flip toENABLE_C_BOUNDS_SAFETY. - The `__ptrcheck_unavailable_r` migration guarantee at §5.1 becomes partial. The attribute only fires under
-fbounds-safety, so callers of legacy entry points in skipped files compile silently against the shim. Callers in adopted files are still caught at compile time; callers in skipped files need manual audit if you want full migration. - The target's ABI is no longer uniform. Today the workflow introduces only
__single-ABI annotations on cross-TU functions, so this is not actively a problem — but any future use of__bidi_indexableor__indexableon an internal cross-TU function would create an ABI mismatch with callers in skipped files (wide pointer layout differs from a plain pointer).
Wait for the user's explicit answer.
2. On approval:
- Ensure a per-file
Adopt -fbounds-safety in <file>task exists for the skipped file. If Moment B has already run, it does; otherwise (the skip was declared upfront during §0)TaskCreateit now so every skip has the same task representation regardless of when it was declared.TaskUpdatethat task tocompletedwith a one-line noteskipped: <reason>. If Moment C sub-tasks already exist for the file, mark eachcompletedwith the same note. TaskUpdatethe §4 task tocompletedwith a one-line noteskipped: file(s) <X, Y, …> not adopted; per-file flags retained for adopted files. If the §4 task was already marked complete-with-note by a previous skip, append the new file to the running list (re-edit the note viaTaskUpdate).- No dependency rewiring is needed: §5.x umbrellas are already
addBlockedBy [<step 4 task ID>], so marking §4 complete naturally unblocks them once the remaining per-file tasks finish.
3. Handle any in-progress adoption state on the skipped file (mid-stream only). If the per-file -fbounds-safety flag was already toggled on for this file, or source changes toward adoption were already started, stop and ask the user how to handle the uncommitted working-tree changes for this file. The default recommendation is to revert them — otherwise the file is left in a half-broken state (e.g. flag on but adoption incomplete). Apply the user's answer before moving on.
Then continue with the next per-file task if mid-stream.
4. Switch to target-level enablement
Run this step only if every file in the target was adopted. Otherwise (some file skipped via Skipping a file's enablement) §4 is bypassed and the workflow proceeds directly to §5.1.
When every file has been adopted it is preferable to enable -fbounds-safety at the target level rather than continuing to carry per-file flags. See build-settings.md for the Xcode build settings. This change should be its own commit. Clear the per-file -fbounds-safety flag from every adopted file before flipping the target-wide setting.
5. Post-target-level refinements
Project-wide source-level cleanups that depend on every translation unit being uniformly under -fbounds-safety. Step 4 made that uniformity ABI-atomic — once it lands, no caller in this target can be left in a non-bounds-safety build. Under partial-target adoption (§4 bypassed via Skipping a file's enablement), this section's per-item tasks still execute, but the uniformity guarantee does not hold — see each sub-step's caveats.
Each 5.x sub-step is structured as:
- Per-item tasks (created in earlier phases; one per unit of work). Gated by Step 4. Track per-item state. While processing them, make the source change and mark complete — do not commit between items.
- One umbrella checkpoint task (
5.x Commit <substep> batch). Blocked by every per-item task. When all per-item tasks are complete, this surfaces. Its body is the verify-stop-and-commit sequence for that sub-step (defined per-substep below).
5.1 Safe Wrapper retrofits
Before doing this step, re-read `language-overview.md` and `common-patterns-and-pitfalls.md` in full via the Read tool.
For every public-API function captured during Phase 1 as a per-item Add Safe Wrapper for <funcName> task (struct fields are out of scope), apply the Safe Wrappers for Public APIs pattern.
Mark each per-item task complete after the source change for that wrapper is applied. Move on to the next per-item task. Do not commit.
When all per-item Safe Wrapper tasks are complete, the 5.1 Commit Safe Wrapper batch task surfaces. Its body:
1. Verify the target still compiles. Fix any compilation errors introduced by the batch. (Note: the legacy entry points are `__ptrcheck_unavailable_r`, so an un-switched caller is a compile error here — this step is what guarantees every caller migrated. Under [partial-target adoption](#skipping-a-files-enablement), the attribute only fires in adopted TUs; callers in skipped files keep compiling against the legacy shim.) 2. Run the project's tests. Use the same test command captured during the Confirm how to run tests task in Moment A. Fix any failing tests. (Skip if the user could not provide a way to run the tests, mirroring §3 step 3.) 3. Stop and ask the user to review the changes before committing. Mirror §3 step 4's structure — communicate, in this order: 1. Identify the scope. Tell the user something like: "The changes introduce Safe Wrappers on the unsafe interfaces identified earlier. Each legacy function is now a thin shim that delegates to a `Safe variant with explicit count parameters, and every internal caller has been redirected to use the Safe` variant directly." Then list which functions were wrapped. 2. Explain what will happen on approval: a single commit (or one tightly-related cluster) covering the entire batch. Unlike per-file enablement — which committed the source changes and the build-system change separately — this is one source-only commit; there's no build-system component. 3. Invite the user to inspect the changes, make any further changes they need, and approve when ready to commit.
Then summarize the actual changes. Wait for explicit approval. If the user requests adjustments, apply them, re-verify (steps 1 and 2), and re-present. 4. On approval, commit following the Commit hygiene at review stops procedure. Scope: source-code only (the wrapper functions, the legacy shim retypings, the __ptrcheck_unavailable_r markers, and every caller switched to *Safe).
6. Initial Adoption Complete
At this point initial -fbounds-safety adoption is complete. Tell the user adoption is done and surface these follow-ups for them to consider — the skill does not perform them:
- Additional testing to look for runtime bounds-check failures. Exercising the code beyond the existing test suite (e.g. fuzzing, broader integration tests) can uncover bounds violations that compile-time checking did not catch.
- Benchmark and optimize if needed. Measure performance and binary size against the pre-adoption baseline. If overhead is unacceptable, optimization may be needed.
Use of unsafe constructs
language-overview.md contains several escape hatches (e.g. __unsafe_indexable and __unsafe_forge_* intrinsics). Use of these constructs should be avoided when possible.
Common Patterns, Tips, and Pitfalls
For common patterns (local variables to avoid assignment restrictions, handling incompatible APIs, calling non-adopted libraries, choosing between __indexable and __bidi_indexable) and common pitfalls encountered during adoption, see common-patterns-and-pitfalls.md.
Soft Trap Mode
Soft traps log violations instead of terminating the program, allowing you to discover multiple issues without fixing them one at a time. This is useful for:
- At-desk debugging: attach a debugger, observe all soft traps, then fix
- Identifying all bounds violations in a test suite in a single run
See build-settings.md for how to enable soft trap mode, and runtime-debugging.md for how to debug soft traps in LLDB.
Note soft traps do not enforce bounds safety so to get any benefit from -fbounds-safety soft trap mode must be switched off for adoption to be considered complete.
Performance Optimization
Use optimization remarks to identify where bounds checks are emitted. Strategies to reduce overhead:
- Adjust loop conditions so bounds checks match loop bounds (optimizer removes redundant checks)
- Reorder loops to iterate from size to zero (bounds check often hoisted outside loop)
- Add manual bounds checks before tight loops to make inner checks redundant
- Avoid complex count expressions (e.g., division is expensive in count expressions)
Header-Only Adoption
Header-only adoption is a lightweight alternative for libraries that don't want the cost of full adoption — either in terms of engineering time or runtime overhead.
When to Use
- Your library is consumed by clients that are adopting
-fbounds-safety - You want to provide safe interfaces without changing your implementation
- You want to avoid runtime overhead in your library
Tracking adoption progress
Header-only adoption is bounded — three numbered steps, with §3 being an opt-in Safe Wrapper batch. Use TaskCreate once at the start so the user can see the plan and no step is silently dropped. Before any file is modified, create exactly these tasks:
Confirm approach with the user(header-only vs full adoption)1. Annotate public headers(per 1. Headers First)2. Create validation file and commit(per 2. Create a Validation File)3a. Confirm Safe Wrapper application(gate task — its body asks the user whether to apply captured wrappers, or auto-completes if none captured; see 3. Safe Wrapper retrofits (if any captured))3b. Commit Safe Wrapper batch(umbrella — auto-completes with no commit if3a.cleared with "no Safe Wrappers captured", "user declined", or amendment declined every captured wrapper. Otherwise runs the verify-stop-and-commit body in §3 over the remaining (approved) wrappers.)4. Header-only adoption complete(final milestone — its body is described in §4)
Wire the chain with TaskUpdate addBlockedBy so order is enforced and the milestone only surfaces at the end:
- Task
2.is blocked by task1.. - Task
3a.is blocked by task2.. - Task
3b.is blocked by task3a.. - Task
4.is blocked by task3b..
During §1, the Capturing deferred Safe Wrapper retrofits subsection may create per-item Add Safe Wrapper for <funcName> tasks. In header-only mode their wiring is addBlockedBy [<3a task ID>], addBlocks [<3b task ID>] — so per-items unblock once 3a. clears (user approves) and 3b. waits for them all.
Mark a task completed only when its step is actually done. If a step legitimately does not apply, mark complete with a one-line note explaining why rather than skipping silently. In particular: if no per-item Safe Wrapper tasks were created during §1, mark 3a. complete with a one-line "no Safe Wrappers captured" note when it surfaces, and 3b. will auto-complete with the same note.
Steps
The header-annotation work and validation-file work are the same as the corresponding steps in Full Adoption. Follow these sub-sections in order:
1. [1. Headers First](#1-headers-first) — annotate the public headers and add __ptrcheck_abi_assume_single(). 2. [2. Create a Validation File](#2-create-a-validation-file) — create a .c file that includes all adopted headers and compiles with -fbounds-safety. 3. [3. Safe Wrapper retrofits (if any captured)](#3-safe-wrapper-retrofits-if-any-captured) — apply captured Safe Wrappers (after asking the user whether to proceed) and commit. Defined in the new subsection below. 4. [4. Header-only adoption complete](#4-header-only-adoption-complete) — tell the user adoption is done and surface follow-up suggestions (notably: consider full adoption in the future).
Do not proceed to Full Adoption's "3. Enable Per-File in Implementation" — that is a different step (despite sharing the same number) and applies only to full adoption. Header-only's §3 above is distinct.
Compiling the validation file (step 2 above) requires -fbounds-safety as a per-file build flag.
3. Safe Wrapper retrofits (if any captured)
Before doing this step, re-read `language-overview.md` and `common-patterns-and-pitfalls.md` in full via the Read tool.
This step applies the Safe Wrappers for Public APIs pattern to any per-item Add Safe Wrapper for <funcName> tasks captured during §1's Capturing deferred Safe Wrapper retrofits subsection. It is gated on user opt-in: header-only adoption defaults to "no source-file work," so we ask before doing it.
The step is split across two tasks (3a. and 3b.) plus the per-item tasks captured during §1.
3a. body — opt-in gate
1. No-captures shortcut. If no Add Safe Wrapper for <funcName> per-item tasks were created during §1, mark 3a. complete with a one-line "no Safe Wrappers captured" note. 3b. will auto-complete with the same note when it surfaces. 2. Opt-in stop. Otherwise, stop and ask the user whether to apply the captured wrappers. Communicate, in this order: 1. List the candidate wrappers (function names, with the one-line "Reason for __unsafe_indexable" captured during §1). 2. Explain that applying these means modest source-file changes — new *Safe variants in the implementation file, the legacy functions become thin shims that delegate to their *Safe variant, and the legacy declarations are marked __ptrcheck_unavailable_r in the public header. Internal callers of the legacy API are not re-routed — they continue to call the legacy function (which now goes through the shim), so existing implementation code is left as-is. 3. Ask whether to proceed, decline, or amend the candidate list. Make explicit that declining (or amending to drop every wrapper) results in zero source-file changes and zero commits — the captured per-item tasks are simply marked completed with a "user declined" note and adoption proceeds to the milestone. 3. Apply the answer.
- On decline: mark every per-item
Add Safe Wrapper for <funcName>task complete with a "user declined" note, mark3a.complete with the same note, and let3b.auto-complete with the same note when it surfaces. No commit. - On amendment: edit the candidate list per user direction (e.g. mark a subset declined, leave the rest pending), then mark
3a.complete. - On approval: mark
3a.complete. Per-items unblock and you work each one (next subsection).
Per-item application (between 3a. and 3b.)
For each remaining Add Safe Wrapper for <funcName> per-item task, apply the Safe Wrappers for Public APIs pattern, with the Header-only variant adjustments. Three reminders specific to this mode:
- Do not switch internal callers — header-only adoption deliberately leaves internal callers of the legacy API alone, so the only caller of
<funcName>Safein the implementation is the shim itself. This keeps the implementation-file footprint minimal. - The implementation file is not under `-fbounds-safety`. Do not add
__unsafe_forge_*calls in the legacy shim — they are no-ops here and just clutter the diff. Conversely, do still write the Safe variant's definition with the same parameter annotations as the header declaration so the redeclaration is consistent and the signature is ready for full adoption later. - Ensure `<ptrcheck.h>` is reachable in the implementation file. The annotation macros need it to expand to empty when the flag is off (see language-overview.md). Usually transitive via the public header; add
#include <ptrcheck.h>directly if not.
Mark each per-item complete after its source change is applied. Do not commit between per-items.
3b. body — verify, stop, commit
When 3b. surfaces, branch on the state left by 3a.:
- If `3a.` cleared with "no Safe Wrappers captured" or "user declined" (or every per-item was marked declined during the amendment branch): mark
3b.complete with the same one-line note as3a.and stop. No verify, no review, no commit — there are no source changes to commit. - Otherwise (
3a.approved and at least one per-item was applied), run the body below. (Header-only mode does not capture a test command, so the build alone is the verification gate; users wishing to run tests should do so manually before approving the review stop.)
1. Verify the target still compiles. Fix compilation errors. 2. Stop and ask the user to review before committing. Mirror §5.1 step 3's structure — communicate, in this order: 1. Identify the scope. Tell the user something like: "The changes introduce Safe Wrappers on the unsafe interfaces identified when annotating the public headers. Each legacy function is now a thin shim that delegates to a `Safe` variant with explicit count parameters. Internal callers of the legacy API are unchanged — they continue to call the legacy function (which now goes through the shim), so the implementation footprint stays minimal."* Then list which functions were wrapped. 2. Explain what will happen on approval: a single commit (or one tightly-related cluster) covering the entire batch — source-only, with no separate build-system commit. 3. Invite the user to inspect the changes, make any further changes they need, and approve when ready to commit.
Then summarize the actual changes. Wait for explicit approval. If the user requests adjustments, apply them, re-verify (step 1 above), and re-present. 3. On approval, commit following the Commit hygiene at review stops procedure. Scope: source-code only (the new *Safe definitions, the legacy shim rewrites, and the __ptrcheck_unavailable_r markers in the public header).
4. Header-only adoption complete
At this point header-only -fbounds-safety adoption is complete. Tell the user adoption is done and surface these follow-ups for them to consider — the skill does not perform them:
- Consider full adoption in the future. Header-only protects external clients of the library; the library's own implementation is not compiled with
-fbounds-safety, so bugs inside the implementation are not caught at compile time and out-of-bounds accesses inside the implementation are not trapped at runtime. If stronger guarantees are wanted later, Full Adoption extends bounds-safety to the implementation itself. The work already done — annotated public headers, the validation file, and any Safe Wrappers applied — carries forward and accelerates a future full-adoption pass. - *If Safe Wrappers were applied, exercise the new `Safe` variants.** The new code paths should be tested to ensure correctness.
What Clients Get
- Clients adopting
-fbounds-safetysee the annotated interface and get bounds checks at call sites - The compiler verifies at the client's call site that the pointer has at least
countelements - Other clients that don't use
-fbounds-safetysee the same header with no effect — annotations are invisible without the flag
What You Don't Get
- No bounds checking inside your library's implementation
- No compiler enforcement of annotation correctness within implementation files
- Bugs in your implementation are not caught by
-fbounds-safety
Useful for Cross-Language Interop
Header-only annotations also provide more information to the compiler for safer interop from other languages (e.g., Swift importing your C headers).
Build Settings for -fbounds-safety
This document covers compiler flags, build system configuration, and related settings for enabling -fbounds-safety.
Enabling -fbounds-safety
Per-File Enablement (Recommended for Incremental Adoption)
Most projects adopt -fbounds-safety incrementally, enabling it one file at a time as a per-file build flag. See adoption-strategies.md for the adoption workflow.
Project-Wide Enablement (After Adoption Is Complete)
Once adoption is complete across an entire target or project, you can enable -fbounds-safety globally. This is desirable because it controls enablement from a single location, making it easier to switch on or off.
Xcode: Add the custom build setting ENABLE_C_BOUNDS_SAFETY=YES. This applies -fbounds-safety only to C files — it will not bleed onto C++, Objective-C, or Objective-C++ files (unlike adding the flag to project-level C flags directly, which would).
Other Build Systems: Pass -fbounds-safety to Clang for each C source file.
No additional link-time libraries are required. Clients (including non-bounds-safe ones) should be oblivious to the change.
Useful Flags
-ferror-limit=0
Removes the limit on compiler errors. Useful during adoption to see all diagnostics at once rather than fixing errors one batch at a time.
-ffreestanding
For projects without access to a strlen implementation. When converting __null_terminated pointers to indexable, -fbounds-safety may insert a strlen call. The -ffreestanding flag makes the compiler generate a character-counting loop instead.
-fbounds-safety-unique-traps
Prevents trap merging in optimized builds. By default, the optimizer merges all traps in a function into one (to reduce code size), making it difficult to determine which specific bounds check failed. This flag preserves separate trap locations, making optimized-build debugging much easier.
-fbounds-safety-soft-traps=call-minimal
Enables soft trap mode. Soft traps log violations instead of terminating the program — the compiler emits calls to __bounds_safety_soft_trap instead of trap instructions, allowing execution to continue after a bounds check failure. This is useful during adoption to discover multiple issues in a single run rather than fixing them one at a time. After all files compile and all traps are fixed use of soft trap mode must be removed to actually get the security benefit.
Xcode: Add the build setting CLANG_BOUNDS_SAFETY_SOFT_TRAPS=call-minimal. This enables soft trap mode for every source file that uses ENABLE_C_BOUNDS_SAFETY. For files where you manually pass -fbounds-safety, add the flag directly.
Other build systems: Pass -fbounds-safety-soft-traps=call-minimal to every source file that uses -fbounds-safety.
See runtime-debugging.md for more information on debugging with soft traps.
Common Patterns and Pitfalls
This document covers common patterns for working with -fbounds-safety and pitfalls encountered during real-world adoption.
Common Patterns
Using Local Variables to Avoid Assignment Restrictions
When the compiler requires pointer and count to be assigned together (the "dependent variable" rule), introduce local variables:
// This causes an error — buf and count must be assigned together:
void fill(int *__counted_by(count) buf, size_t count) {
while (count-- > 0) {
*buf = count;
buf++; // error: assignment to 'buf' requires corresponding assignment to 'count'
}
}
// Fix: copy to local variables (implicitly __bidi_indexable):
void fill(int *__counted_by(countOrig) bufOrig, size_t countOrig) {
int *buf = bufOrig;
size_t count = countOrig;
while (count-- > 0) {
*buf = count;
buf++; // OK — buf is __bidi_indexable, no external bounds to maintain
}
}Data Organization: Prefer Rows Over Columns
When a struct contains pointer fields, prefer "row" organization (array of structs) over "column" organization (struct of arrays):
// Row organization (recommended) — flat pointers, easy to annotate:
struct gpio_config {
uint32_t cfg;
uint32_t *__counted_by(intStatusCount) intStatus;
uint32_t intStatusCount;
};
struct gpio_config configs[N];
// Column organization (problematic) — nested pointers, hard to annotate:
uint32_t **intStatusArray; // cannot express __counted_by for inner pointersRewriting Internal APIs
When an internal function's signature has pointers that cannot be made safe using ABI-compatible bounds annotations (like __counted_by or __sized_by), the ABI-incompatible __bidi_indexable can be used to propagate bounds because the ABI doesn't need to be preserved. This is much preferable to using __unsafe_indexable.
In this example, an internal function originally had an out-parameter with no bounds information. By using __bidi_indexable, bounds from the internal fixed-size buffer propagate to callers:
// Before: no bounds on out-parameter
static int GetExtNext(Handle *H, uint8_t **Out);
// After: __bidi_indexable propagates bounds from internal buffer
static int GetExtNext(Handle *H, uint8_t *__bidi_indexable *Out) {
...
// H->Buf is a fixed-size array (e.g., uint8_t Buf[256]).
// Assigning it through a __bidi_indexable * out-parameter
// gives the compiler array bounds automatically — no forge needed.
*Out = H->Buf;
...
}Using __bidi_indexable / __indexable in a Source File That Must Compile Without -fbounds-safety
Before reaching for this pattern, prune. Check each __bidi_indexable / __indexable against Redundant `__bidi_indexable` / `__indexable` Annotations below. Locals already default to __bidi_indexable, and casts on expressions that are already (or can implicitly become) __bidi_indexable don't need the annotation. If pruning leaves no remaining uses in this file, you don't need this pattern at all.
When this pattern applies (after pruning). A .c file still uses __bidi_indexable (or __indexable) by name — on internal helper signatures, on local variable declarations where the annotation is load-bearing, or inside cast expressions where the annotation is load-bearing — and must also compile cleanly with -fbounds-safety off (e.g. for the two-commit-dance source-changes commit in adoption-strategies.md).
Pattern. At the top of the .c file, after #include <ptrcheck.h>:
#if !__has_ptrcheck
/* ptrcheck.h leaves these undefined when -fbounds-safety is off to force
* compile errors on ABI-breaking uses in headers. In this .c file the
* annotations only appear on static helpers (no ABI surface), so it is
* safe to define them as no-ops here. */
#define __bidi_indexable
#define __indexable
#endifConstraints:
- Never put this in a header file. Headers are shared across translation units; silently no-op'ing an ABI-breaking attribute risks an ABI mismatch between a header that defines the fallback and a TU that doesn't.
- Only when the annotated declarations are not ABI-visible. Static helpers and local variables are fine; an
externfunction in this.cfile whose signature includes__bidi_indexableis not — its declaration in another TU would see a different ABI. - Do not also add `#if __has_ptrcheck` guards around forge/conversion intrinsic call sites. Those have fallbacks in
ptrcheck.h(see Unnecessary `#if __has_ptrcheck` Guards below).
Constant Bounds on Externally-Counted Pointers
Examples below use __counted_by(N) for concreteness; the same reasoning applies to every externally-counted pointer kind: __counted_by, __counted_by_or_null, __sized_by, __sized_by_or_null, __ended_by.
Cardinal rule: derive `N` from what the function body alone provably accesses, including fixed offsets, fixed-size operations, bounds flowing through annotated callees, and the static type of an index variable the body doesn't narrow further. Not from caller data, allocation patterns, or format/protocol spec invariants the body doesn't enforce.
A constant N is correct only if the function body provably accesses at most N elements/bytes for every input — counting direct accesses, sequences, fixed-size operations (e.g. memcpy(dst, src, 4)), and bounds flowing through annotated callees. Specifically, N must not come from:
- Runtime contents of the input. Example:
f(const Header *H, T *buf)readsbuf[H->indices[k]]; the reachable bound onbufdepends on what values are inH->indicesat runtime — pure data, not contract. - A size/count attached to the input that the count-expression grammar can't reference directly. Tempting when the real bound (e.g.
P->capacity) is rejected by the grammar (see Count Expression Grammar); substituting a constant ceiling is not a fix. - Format/protocol invariants about valid inputs. Reasoning "the spec caps it at
N, so useN" ties the API to the format definition, not to what the function actually accesses. - Allocation patterns of any particular caller. Example: an in-tree caller declares
T buf[256]on its stack and passes it in; reflecting that 256 into the public API encodes one caller's choice as if it were a contract.
Honest examples — functions whose body unconditionally accesses a fixed set of indices/offsets, the same for every input:
- Writing the four bytes of a fixed-length protocol header by assigning
header[0]..header[3]→__counted_by(4). - Always calling
memcpy(dst, src, 16)against a fixed-layout block →__sized_by(16).
Audit procedure before writing any constant N:
1. Open the function body; identify the highest index/byte offset the function can reach, across all paths and inputs. 2. Complete: "the function genuinely accesses up to <constant> elements/bytes because ___". If the answer is the body's own behaviour — including the static type of an index the body doesn't narrow — the constant is fine. If it lands in any of the four categories above, the constant is wrong — go to the remedy below.
Remedy when the audit fires. Branch on visibility:
- Public API (declared in a published header / consumed by external clients): apply Safe Wrappers for Public APIs — the public function becomes a thin shim with its pointer parameter re-annotated
__unsafe_indexable, delegating to a new*Safevariant that takes an explicit count. - Internal (
static, or declared only in private headers): use ABI-incompatible annotations directly — see Rewriting Internal APIs.__bidi_indexablepropagates bounds from the caller with no count parameter; alternatively, add an explicit count and use dynamic__counted_by(count)/__sized_by(count).
Anti-pattern walkthrough. A function void apply_lookup(const Header *H, const T lookup[]) declared in a public header, where the format spec restricts H->indices[k] to [0, 16). Wrong adoption: lookup[__counted_by(16)], reasoned from "the spec caps the index at 16." Audit step 2: "the function genuinely accesses up to 16 elements because the spec says so" — that's the format/protocol-invariants category, not the body's own behaviour (the body indexes via uint8_t and never narrows; if a corrupted H->indices[k] produced 17, the body would read lookup[17]). Audit fires; visibility = public → Safe Wrapper. The *Safe(H, lookup, len) variant lets the caller declare the actual table length, and -fbounds-safety then traps when the runtime index exceeds it — catching data corruption at the indexing site. Had this function been declared static, the internal remedy would apply instead.
Safe Wrappers for Public APIs
This pattern applies to public APIs (declared in shipped headers, consumed by external clients, ABI must be preserved). For internal-only signatures, Rewriting Internal APIs above is the simpler remedy. Use Safe Wrapper for a public function when any of these apply:
- The natural bound is a struct field of another parameter (
->and.are rejected in count expressions; see Count Expression Grammar) - The natural bound requires arithmetic on a dereferenced pointer (e.g.
*count + 1, also rejected) - The natural bound requires calling a function that isn't marked
__attribute__((const))— only const-attributed functions are accepted in count expressions, so anything with side effects or hidden state (e.g. a non-conststrlen-style helper) can't be referenced - The natural bound is a function-local quantity not present in the existing public signature
- A constant
__counted_by(N)appears to fit but the actual access is bounded by a dynamic quantity — see Constant Bounds on Externally-Counted Pointers above __unsafe_indexableis otherwise the only option
Create a bounds-safe internal implementation and reduce the public function to a thin shim:
1. Move all implementation logic into a new internal safe function 2. The original public function becomes a thin shim that delegates to the safe version 3. Internal callers call the safe function directly — never the legacy shim. (Skip in header-only adoption — see [§3 Safe Wrapper retrofits](adoption-strategies.md#3-safe-wrapper-retrofits-if-any-captured) for why.) 4. Mark the legacy function's declaration with __ptrcheck_unavailable_r(safe_function_name) — this makes it unavailable in -fbounds-safety builds while keeping it available for non-adopted callers. The attribute only needs to be on the declaration, not the definition.
Example:
// Header — mark legacy API unavailable in -fbounds-safety builds
__ptrcheck_unavailable_r(UnionSafe)
Result *Union(const Map *A, const Map *B,
Pixel *__unsafe_indexable trans);
// Public safe version with explicit count
Result *UnionSafe(const Map *A, const Map *B,
Pixel *__counted_by(transLen) trans, int transLen) {
// full implementation here
}
// Legacy wrapper — forges and delegates
Result *Union(const Map *A, const Map *B,
Pixel *__unsafe_indexable trans) {
Pixel *safe = __unsafe_forge_bidi_indexable(
Pixel *, trans, B->Count * sizeof(Pixel));
return UnionSafe(A, B, safe, B->Count);
}Internal callers use the safe version directly, never the legacy wrapper:
void MergeColorMaps(const Map *A, const Map *B,
Pixel *__counted_by(B->Count) trans) {
// Calls UnionSafe directly — not Union
Result *merged = UnionSafe(A, B, trans, B->Count);
...
}Header-only variant. When the Safe Wrapper is being applied as part of header-only adoption (see §3 Safe Wrapper retrofits), the implementation file is not compiled with -fbounds-safety. Three adjustments to the shape above:
- Drop the forge in the legacy shim. With the flag off in the impl,
__unsafe_indexableand__counted_by(...)are both just plain pointers — passing the legacy parameter directly to the*Safevariant compiles cleanly. Add a forge only if the file is later switched to full adoption. - *Keep the annotations on the Safe variant's definition*** so it matches the header declaration verbatim. Per language-overview.md
ptrcheck.hexpands the annotations to empty when the flag is off, so they are inert at the impl's compile site — but they are required for redeclaration consistency and they keep the signature ready for full adoption later. - Ensure `<ptrcheck.h>` is reachable in the implementation file. The annotation macros (
__counted_by,__counted_by_or_null, etc.) come fromptrcheck.h; without it the macros are undefined and the file won't compile even with-fbounds-safetyoff. Typically the impl already includes the public header you just annotated (which itself includesptrcheck.h), so this is automatic — but if the impl gets its types from a private header that doesn't transitively pull inptrcheck.h, add#include <ptrcheck.h>directly.
Concretely, the legacy shim from the example becomes:
// Legacy wrapper — header-only mode, no forge
Result *Union(const Map *A, const Map *B,
Pixel *__unsafe_indexable trans) {
return UnionSafe(A, B, trans, B->Count);
}The UnionSafe definition is unchanged from the full-adoption example.
- No
__unsafe_forge_*calls should be needed to satisfy the safe function's parameter and return types — the forge belongs in the legacy wrapper, not at internal call sites - Internal code must never call the legacy wrapper — always call the safe version directly
- The legacy wrapper exists purely for API/ABI backwards compatibility
- Forward-declare safe functions as
staticonly if needed for ordering (e.g., mutual recursion between related safe functions)
Coordinating with the adoption workflow. If you decide on a Safe Wrapper during the headers-first phase (Phase 1 in adoption-strategies.md), do not retrofit it inline — Phase 1 is source-file-free, and the retrofit is intrinsically cross-file. Instead, create a per-item Add Safe Wrapper for <funcName> task per the Capturing deferred Safe Wrapper retrofits sub-heading. Execution lands at different points depending on the adoption mode:
- Full adoption: at Step 5.1 Safe Wrapper retrofits, after the project switches to target-level
ENABLE_C_BOUNDS_SAFETY. The5.1 Commit Safe Wrapper batchumbrella task is the single commit point. Under partial-target adoption (some file skipped per Skipping a file's enablement), Step 4 is bypassed and Safe Wrappers still apply at §5.1 — see §5.1's verify-step caveat for what changes. - Header-only adoption: at §3 Safe Wrapper retrofits (if any captured), gated on a user opt-in stop. On approval, the per-items are applied with the "switch internal callers" step skipped — header-only deliberately leaves implementation call sites untouched. The
3b. Commit Safe Wrapper batchumbrella is the single commit point.
Calling Non-Adopted Libraries
ABI-visible pointers in SDK/system headers are __unsafe_indexable by default. When consuming return values or struct fields from these libraries:
- Passing data in: all pointers implicitly convert to
__unsafe_indexable— no issues - Getting data out: use
__unsafe_forge_bidi_indexableor__unsafe_forge_singleto create safe pointers
// stdin from stdio.h is __unsafe_indexable in system headers:
FILE *f = __unsafe_forge_single(FILE *, stdin);Include external/third-party headers as system headers to prevent compilation errors (they'll default to __unsafe_indexable).
String Variables and __null_terminated
Choosing between __null_terminated and __bidi_indexable
When a variable is used primarily as a C string — passed to string functions like strlen, strtok, strcpy, or iterated with ++p — consider declaring it as __null_terminated. This lets the variable work directly with string functions without conversion at each use site.
Apple's Libc string functions (strlen, strtok, strchr, etc.) accept and return __null_terminated pointers. Declaring a string variable as __null_terminated lets you use these functions directly and avoids repeated __null_terminated to/from __bidi_indexable conversions, which each require a linear scan of the string to find the terminator:
const char *__null_terminated cp;
cp = strtok(buf, "\n"); // strtok returns __null_terminated
strlen(cp); // no conversion needed
strcpy(dst, cp); // no conversion neededIf a non-adopted function returns a pointer you know is null-terminated but the return type is not annotated, use __unsafe_forge_null_terminated to establish the annotation once at the assignment rather than converting at every downstream use.
When NOT to use `__null_terminated`: If the code needs pointer arithmetic beyond +1 (e.g., p += n, p[i] with arbitrary i), use __bidi_indexable instead. __null_terminated only supports +0 and +1 arithmetic.
When you need both: If a string needs both random-access indexing AND string API calls, keep two pointers to the same data — one __null_terminated for string APIs, one __bidi_indexable (via __null_terminated_to_indexable) for indexing. They must be manually kept in sync if either is advanced:
void process(const char *__null_terminated input) {
const char *__null_terminated nt_ptr = input;
const char *idx_ptr = __null_terminated_to_indexable(input);
size_t len = strlen(nt_ptr);
// Random access via indexable pointer
for (size_t i = 0; i < len; i++) {
if (idx_ptr[i] == ':')
printf("colon at offset %zu\n", i);
}
// String API via null-terminated pointer
const char *__null_terminated found = strchr(nt_ptr, ':');
if (found)
printf("found: %s\n", found);
}Converting to __null_terminated cheaply
When converting from __bidi_indexable back to __null_terminated, __unsafe_null_terminated_from_indexable(P) must scan the string to find the terminator (O(n)). If you already know where the terminator is, pass it as a second argument for an O(1) conversion:
char *buf = (char *)malloc(len + 1);
memcpy(buf, src, len);
buf[len] = '\0';
// O(n): scans buf to find the terminator
return __unsafe_null_terminated_from_indexable(buf);
// O(1): we know the terminator is at buf[len]
return __unsafe_null_terminated_from_indexable(buf, &buf[len]);Choosing Between __indexable and __bidi_indexable
__indexableis 2 register words — passed by register, lower overhead__bidi_indexableis 3 register words — passed by stack copy, higher overhead- Conversions between them are implicit
Guidance:
- For function arguments/returns that must use wide pointers, prefer
__indexable - Within functions, use the default
__bidi_indexable— no performance penalty for local use - Don't use
__indexableas a security measure;__bidi_indexablealready prevents out-of-bounds below the lower bound - When possible, prefer external bounds annotations (
__counted_by, etc.) over either wide pointer type
Common Pitfalls
These are common issues encountered during real-world adoption, along with recommended solutions.
Casting to a Larger Struct Type Traps at Runtime
Problem: Casting a pointer to a struct type that is larger than the pointed-to memory will trap when any field is accessed via ->, even if the specific field being accessed is within bounds.
struct element_t {
uint8_t id;
uint8_t len;
uint8_t data[10]; // sizeof(element_t) == 12
};
uint8_t buffer[8];
struct element_t *cast_buffer = (struct element_t *)buffer;
cast_buffer->id; // TRAPS — even though id is at offset 0Why: When accessing a struct field via ->, -fbounds-safety checks that the entire struct is within bounds, not just the field being accessed. This prevents intra-object overflow and avoids undefined behavior.
Fix: Use a smaller header struct that fits within the actual buffer size, or parse by reading fields individually rather than casting the buffer:
struct header {
uint8_t id;
uint8_t len;
};
struct header *hdr = (struct header *)buffer;
if (hdr->id == EXPECTED_TYPE) {
// Now safe to access more data knowing the type
}Casting Between __single Pointers Can Widen Bounds
Problem: Casting between __single pointers of different struct types can silently increase the assumed bounds, because __single assumes one valid element of the destination type.
struct small { int a; }; // 4 bytes
struct large { int a; int b; }; // 8 bytes
struct small s = {0};
struct small *__single r = &s;
struct large *__single q = (struct large *)r;
q->b; // NO trap — but accesses memory beyond 's'!Why: A __single pointer assumes it points to one valid element of its type. Casting to a larger type changes that assumption. This differs from __bidi_indexable, which preserves the original bounds and would trap.
Fix: Be careful with __single pointer casts between types of different sizes. If you need the bounds-checked behavior, copy to a local variable (which becomes __bidi_indexable) before casting.
Passing __counted_by/__sized_by Count to Non-Adopted Function
Problem: Passing the count variable of a __counted_by/__sized_by pair to a non-adopted function produces an error about unsynchronized dynamic count pointers.
void do_work(void *__sized_by(*output_len) output, size_t *output_len) {
// unannotated_func is not annotated with -fbounds-safety
unannotated_func(output, output_len);
// error: passing 'output_len' referred to by '__sized_by' to a parameter
// that is not referred to by the same attribute
}The signature shape above — *__sized_by(*output_len) output, size_t *output_len — is the fill-in-place in-out pattern covered in language-overview.md.
Why: -fbounds-safety cannot guarantee the non-adopted function won't modify *output_len in a way that desynchronizes it from the pointer's actual bounds.
Fix: Use a local copy of the count variable:
void do_work(void *__sized_by(*output_len) output, size_t *output_len) {
size_t local_len = *output_len;
unannotated_func(output, &local_len);
*output_len = local_len;
}Slicing a __bidi_indexable Buffer
Problem: You have a __bidi_indexable pointer and need to create a sub-range (a slice) with tighter bounds.
Fix: Assign the pointer through a function parameter with __sized_by or __counted_by to create new bounds:
void *__bidi_indexable slice(void *__sized_by(n) p, size_t n) {
return p;
}
// Usage:
void *__bidi_indexable full_buffer = ...;
void *__bidi_indexable sub = slice((char *)full_buffer + offset, length);Annotating Malloc-Like Functions
Problem: Custom allocation functions need bounds annotations on their return value.
Fix: Use __sized_by_or_null on the return type (since allocation can fail and return NULL):
uint8_t *__sized_by_or_null(size) _Nullable
my_allocate(size_t size);If the function has the alloc_size attribute, -fbounds-safety may infer bounds automatically.
Working with __counted_by Parameters
Problem: Pointer arithmetic or reassignment on __counted_by parameters requires keeping the pointer and count in sync, which is cumbersome.
Fix: Copy both the parameter and its count to local variables at the start of the function. The local pointer becomes __bidi_indexable and the local count is no longer a dependent variable:
void process(int *__counted_by(count) buf_param, size_t count) {
int *buf = buf_param; // buf is now __bidi_indexable
size_t n = count; // n is no longer tied to buf_param
while (n-- > 0) {
*buf = 0;
buf++; // OK — no need to keep count in sync
}
}Passing Arrays to __counted_by Parameters
Problem: Using &array instead of array when passing to a __counted_by parameter causes a type mismatch.
uint32_t arr[10];
void process(uint32_t *__counted_by(size) data, size_t size);
process(&arr, 10); // error: incompatible pointer types
process(arr, 10); // OK — array decays to pointerWhy: &arr has type uint32_t (*)[10] (pointer to array), not uint32_t * (pointer to element). This is standard C behavior, not specific to -fbounds-safety.
Fix: Use arr directly (array-to-pointer decay) or &arr[0].
Unnecessary Forges on Allocator Returns
Problem: Using __unsafe_forge_bidi_indexable on the return value of malloc/calloc/realloc (or any allocator with alloc_size) when assigning to a __counted_by or __sized_by field.
struct container {
int count;
Item *__counted_by(count) items;
};
// WRONG — forge is redundant
Item *new_items = (Item *)realloc(c->items, newCount * sizeof(Item));
c->count = newCount;
c->items = __unsafe_forge_bidi_indexable(
Item *, new_items, (size_t)newCount * sizeof(Item));Why: Allocators with alloc_size already return __sized_by_or_null pointers. Casting to a typed pointer gives a __bidi_indexable with correct bounds. The __bidi_indexable → __counted_by(N) assignment is implicit with a bounds check (per the conversion table). The forge re-derives bounds the compiler already knows.
Fix: Assign the allocator result directly:
Item *new_items = (Item *)realloc(c->items, newCount * sizeof(Item));
c->count = newCount;
c->items = new_items; // compiler inserts bounds check automaticallyRule of thumb: Only forge when the pointer source has no bounds information (e.g., __unsafe_indexable from a non-adopted API). Never forge a pointer from an annotated allocator — one with alloc_size, __sized_by_or_null, or similar return-type annotations. Standard library malloc/calloc/realloc have alloc_size; custom allocators only carry bounds if explicitly annotated.
Unnecessary Forges on Constant-Sized Arrays
Problem: Using __unsafe_forge_bidi_indexable to "give bounds" to a constant-sized array T arr[N]. Example shape — a struct member accessed via ->:
struct Frame { uint8_t buf[256]; };
// WRONG — forge is redundant
void process(struct Frame *p) {
uint8_t *view = __unsafe_forge_bidi_indexable(
uint8_t *, p->buf, sizeof(p->buf));
/* ... use view ... */
}Why: Under -fbounds-safety, a constant-sized array decays to a T *__counted_by(N) pointer when used as a value. This is true for every source — function parameter, local, global, and struct member — so p->buf already carries the bounds [&p->buf[0], &p->buf[N]). Assigning to a T * local produces __bidi_indexable with those bounds; the forge re-derives them.
Fix: Drop the forge and assign directly:
void process(struct Frame *p) {
uint8_t *view = p->buf; // __bidi_indexable with array bounds
}The same rule applies to T local[N], a global T g_arr[N], and a parameter void f(T arr[N]) (which decays to T *__counted_by(N) per function-prototype array decay). See also Deriving Bounds from Objects and the When NOT to Forge checklist.
Forging a __single Pointer Means the Source Is Misannotated
Problem: You find yourself writing __unsafe_forge_bidi_indexable(T *, p, size) (or another widening forge) where p is a __single pointer — either explicitly annotated __single or implicitly defaulted (ABI-visible struct fields and function parameters usually default to __single; see Default Pointer Attributes for the const char * → __null_terminated exception). The forge papers over the underlying problem: the source annotation claims p points to one object, but the code's behaviour proves it points to a buffer. Two common shapes:
- Struct field:
T *field(implicit__single) on a struct, where consumer code forges a bidi view fromfieldusing sibling-field arithmetic for the size. - Function parameter:
T *p(implicit__single) on a function, where the body forges a bidi view frompto read buffer contents — common shape: length-prefixed buffers where the first byte encodes the payload length.
Fix: Correct the source annotation; do not paper over with forges. Order of preference:
1. An externally counted bounds annotation if the bound is expressible in the count grammar — __counted_by(<expr>) / __sized_by(<expr>) / __counted_by_or_null(<expr>) / __sized_by_or_null(<expr>) / __null_terminated. (For struct fields, also consider the FAM exception; for public functions whose bound needs an extra parameter, consider Safe Wrappers for Public APIs.) 2. If the bound exists but cannot be expressed (e.g. it's encoded in the buffer itself like a length-prefixed block, or it requires arithmetic on nested struct fields that the count grammar rejects), use explicit `__unsafe_indexable` on the source. The forge at use sites is then expressing real information about an honestly-unsafe pointer.
Example — wrong (implicit `__single` + forge at use site, struct-field shape):
typedef struct Frame {
Dimensions Dim; /* contains Width, Height */
uint8_t *Pixels; /* implicit __single — wrong */
} Frame;
void process(Frame *f) {
size_t n = (size_t)f->Dim.Width * f->Dim.Height;
uint8_t *buf = __unsafe_forge_bidi_indexable(uint8_t *, f->Pixels, n);
/* ... use buf ... */
}Right (explicit `__unsafe_indexable`, same forge at use site):
typedef struct Frame {
Dimensions Dim;
uint8_t *__unsafe_indexable Pixels; /* bound = Dim.Width * Dim.Height; not expressible */
} Frame;
void process(Frame *f) {
size_t n = (size_t)f->Dim.Width * f->Dim.Height;
uint8_t *buf = __unsafe_forge_bidi_indexable(uint8_t *, f->Pixels, n);
/* same forge, but now describing an honestly-unsafe pointer */
}Example — wrong (function-parameter shape, length-prefixed buffer):
/* Public API: CodeBlock[0] is the payload length in bytes. */
int put_block(File *f, const uint8_t *CodeBlock); /* implicit __single — wrong */
int put_block(File *f, const uint8_t *CodeBlock) {
const uint8_t *view = __unsafe_forge_bidi_indexable(
const uint8_t *, CodeBlock, 256);
uint8_t len = view[0];
return write_bytes(f, view, len + 1);
}Right (apply [Safe Wrappers for Public APIs](#safe-wrappers-for-public-apis)):
// Header — legacy shim with __unsafe_indexable parameter, plus a new
// count-aware variant. See Safe Wrappers for Public APIs for the full
// 4-step pattern (including __ptrcheck_unavailable_r on the shim).
__ptrcheck_unavailable_r(put_block_safe)
int put_block(File *f, const uint8_t *__unsafe_indexable CodeBlock);
int put_block_safe(File *f, const uint8_t *__counted_by(len) CodeBlock,
size_t len);
// .c — implementation lives in the safe variant.
int put_block_safe(File *f, const uint8_t *__counted_by(len) CodeBlock,
size_t len) {
return write_bytes(f, CodeBlock, len);
}
// .c — legacy shim reads the length prefix and delegates.
int put_block(File *f, const uint8_t *__unsafe_indexable CodeBlock) {
size_t len = (size_t)CodeBlock[0] + 1;
const uint8_t *safe = __unsafe_forge_bidi_indexable(
const uint8_t *, CodeBlock, len);
return put_block_safe(f, safe, len);
}Why it matters: With the implicit __single version, any direct arithmetic or indexing on the source pointer would get a compile-time error ("arithmetic on __single pointer") — which forces callers to forge anyway — but the declared type still lies to anyone reading the header (and to any analysis tooling). The explicit __unsafe_indexable version produces the same compile-time discipline at consumers (they must forge to do arithmetic) while communicating accurate information about the data shape.
Don't reach for `__unsafe_indexable` when the bound can be expressed in the count grammar. Order is: an externally counted annotation (__counted_by / __sized_by / __null_terminated) when the bound fits the grammar → __single (truly single-object) → __unsafe_indexable (last resort). If the only block to expressing the bound is "the count is a sibling parameter you'd have to add to the signature", a Safe Wrapper is the right answer for a public function — see Safe Wrappers for Public APIs.
Unnecessary #if __has_ptrcheck Guards
Problem: It is tempting to wrap every bounds-safety-flavoured call site (__unsafe_forge_bidi_indexable, __null_terminated_to_indexable, __unsafe_null_terminated_from_indexable, etc.) in #if __has_ptrcheck / #else blocks "in case -fbounds-safety is off". This over-guards.
Fix: Don't guard. ptrcheck.h provides flag-off fallbacks for every forge intrinsic and conversion macro — they expand to plain C casts (((T)(P))) or pointer pass-throughs ((P)) when -fbounds-safety is off. Code using them compiles unguarded in both modes.
Example — wrong:
#if __has_ptrcheck
uint8_t *buf = __unsafe_forge_bidi_indexable(uint8_t *, raw_ptr, size);
#else
uint8_t *buf = raw_ptr;
#endifExample — right:
uint8_t *buf = __unsafe_forge_bidi_indexable(uint8_t *, raw_ptr, size);The forge expands to ((uint8_t *)raw_ptr) when the flag is off, which is exactly what the #else branch was doing manually.
The one exception. Any textual occurrence of __bidi_indexable or __indexable in source — whether as an attribute on a declaration, on a function parameter, on a local variable, or inside a cast expression — does need either a #if __has_ptrcheck guard or the per-file fallback #define documented in Using `__bidi_indexable` / `__indexable` in a Source File That Must Compile Without `-fbounds-safety`. The fallback #define approach scales better than per-site guards when there are many uses in one file.
Redundant __bidi_indexable / __indexable Annotations
Problem: Writing __bidi_indexable (or __indexable) explicitly is redundant whenever the surrounding context already provides one. Two common shapes:
- On a local variable declaration whose initializer is already a
__bidi_indexable— locals also default to__bidi_indexable(see language-overview.md §Quick Reference), so the annotation is doubly redundant. - In a cast on an expression that already evaluates to a
__bidi_indexable(e.g. the result of__unsafe_forge_bidi_indexable) or that can be implicitly converted to one (e.g. a__sized_by_or_nullreturn from an annotated allocator likemalloc).
Fix: Drop the annotation.
Examples — wrong:
const char *__bidi_indexable foo = NULL;
int *buf = (int *__bidi_indexable)__unsafe_forge_bidi_indexable(int *, raw, size);
int *buf2 = (int *__bidi_indexable)malloc(n * sizeof(int));Right:
const char *foo = NULL;
int *buf = __unsafe_forge_bidi_indexable(int *, raw, size);
int *buf2 = malloc(n * sizeof(int));Why it matters: Beyond verbosity, each explicit __bidi_indexable you write forces the file to need either a #if __has_ptrcheck guard or a per-file fallback #define to build with the flag off (see Using `__bidi_indexable` / `__indexable` in a Source File That Must Compile Without `-fbounds-safety`) — costs you pay for no benefit, since the surrounding context already provides the same pointer kind.
Runtime Debugging for -fbounds-safety
This guide covers debugging programs built with -fbounds-safety, including trap behavior, LLDB commands, wide pointer inspection, and soft trap debugging.
Optimized vs Unoptimized Builds
Debug unoptimized code when possible. Optimized code is harder to debug because:
- Trap reasons are usually optimized out — you won't know why the program trapped
- All traps in a function are merged into one — difficult to determine which bounds check failed
- Bounds information on wide pointers may be missing — the optimizer removes bounds checks and associated data
If fully unoptimized builds aren't feasible (e.g., code size restrictions), selectively disable optimization on specific functions:
__attribute__((optnone)) void function_to_debug() {
// ...
}Remove the attribute when debugging is complete.
-fbounds-safety-unique-traps Flag
In optimized builds, use -fbounds-safety-unique-traps to prevent trap merging. This preserves separate trap locations, making it possible to identify which specific bounds check failed even in optimized code.
What Happens When a Bounds Violation Occurs
When -fbounds-safety detects an issue at runtime, it executes a trap instruction. This is handled by the environment, usually resulting in program termination.
Debugger — Unoptimized Program with Debug Info
Command Line LLDB
The stop reason shows the bounds check failure:
stop reason = Bounds check failed: Dereferencing above boundsThe "Bounds check failed:" prefix indicates -fbounds-safety caught the issue. After the prefix is a trap reason explaining the problem.
Xcode
Xcode stops at the offending line with an annotation like:
Thread 1: Bounds check failed: Dereferencing above boundsDebugger — Optimized Program
In optimized programs the stop reason is not specific. You need to inspect the assembly to determine if a -fbounds-safety trap was hit.
Note: the precise assembly instructions are not guaranteed to be stable.
arm64/arm64e
(lldb) dis -p
-> 0x100003e60 <+296>: brk #0x5519If the program stopped at brk #0x5519, this is a -fbounds-safety trap.
x86_64
(lldb) dis -p
-> 0x100003e95 <+309>: ud1l 0x19(%eax), %eaxIf the program stopped at ud1l with 0x19 constant, this is a -fbounds-safety trap.
armv7
-fbounds-safety uses the trap instruction. No extra information distinguishes it from other traps. Debug an unoptimized build or step through assembly to confirm.
Crash Logs
Unoptimized with Debug Symbols
The crash log shows an artificial inline frame with the trap reason:
Thread 0 Crashed:
0 parse_ints_O0 0x1025b7a2c Bounds check failed: Dereferencing above bounds + 0 [inlined]
1 parse_ints_O0 0x1025b7a2c parse_ints + 472 (parse_ints.c:39)Frame 0 is artificial — the real crash location is frame 1.
The ESR register on arm64 is annotated with (Breakpoint) UBSAN unknown (0x19), indicating a -fbounds-safety trap.
Optimized or No Debug Symbols
No trap reason frame is present. Look for (Breakpoint) UBSAN unknown (0x19) in the ESR register annotation (arm64 only).
Working with Crash Logs in LLDB
Load crash logs for interactive analysis:
(lldb) command script import lldb.macosx.crashlog
(lldb) crashlog -i /path/to/crashlog.ipsThis creates an artificial debugging session where you can disassemble, read registers, navigate the stack, and examine source code.
Trap Reasons
Trap reasons are human-readable descriptions encoded in debug info as artificial inline frames. They are prefixed with "Bounds check failed:".
(lldb) bt
* thread #1, stop reason = Bounds check failed: Dereferencing above bounds
frame #0: parse_ints_O0`parse_ints [inlined] Bounds check failed: Dereferencing above bounds
* frame #1: parse_ints_O0`parse_ints at parse_ints.c:39:13Trap reasons require debug info and are typically lost in optimized builds.
Example Trap Reasons
- `indexing below lower bound in 'ptr[idx]'`
- `indexing above upper bound in 'ptr[idx]'`
- `Pointer below bounds while casting` — bounds check during cast (e.g.,
__bidi_indexable→__single) with pointer below lower bound - `Pointer to struct below bounds while taking address of struct member` — bounds check during
&p->memberwith p below lower bound
If a trap shows only "Bounds check failed" without further detail, a specific message hasn't been implemented for that case.
Working with Wide Pointers
Examining Wide Pointers
LLDB displays wide pointers with their bounds:
(lldb) p output_buffer
(int *__bidi_indexable) $1 = (ptr: 0x000100404080, bounds: 0x000100404080..0x0001004040a8)ptr:is the current pointer valuebounds:shows lower..upper bound
Out-of-bounds pointers are indicated:
(int *__bidi_indexable) $2 = (out-of-bounds ptr: 0x0001004040a8, bounds: 0x000100404080..0x000100404094)Out-of-bounds wide pointers are allowed to exist but cannot be dereferenced.
Known Limitations
- In optimized code, some wide pointer components may be optimized out — LLDB shows
0x000000000000(indistinguishable from actual NULL) - Partially executing a statement may show incorrect results due to partial wide pointer updates
- If LLDB shows the wide pointer as a raw struct with
ptr,ub,lbfields instead of the expected format, you're using an older LLDB version
Working with Externally Counted Pointers
LLDB shows the count expression (unevaluated) for externally counted pointers:
__counted_by
(lldb) p buffer
(int*) (ptr: 0x000100206210 counted_by: size)__sized_by
(lldb) p buffer
(int*) (ptr: 0x000100206210 sized_by: size)__ended_by
(lldb) p start
(int*) (ptr: 0x0001003041e0 end_expr: end)
(lldb) p end
(int*) (ptr: 0x0001003041f0 start_expr: start)Known Limitations
- LLDB does not automatically evaluate the count expression — you must evaluate it manually
- Type printing omits the bounds annotations (shows
int*instead ofint* __counted_by(size))
Types Without Special Debugger Support
These annotations currently have no special LLDB display — the unannotated pointer type is shown:
__single__terminated_byand__null_terminated__unsafe_indexable
Expression Parsing Limitations
The -fbounds-safety language mode is mostly off in LLDB's expression evaluator. Known issues:
-fbounds-safetytypes cannot be parsed:p (int *__bidi_indexable) foowill fail-fbounds-safetybuiltins cannot be called:__builtin_get_pointer_upper_bound(foo)will fail- Dereferencing a wide pointer in an expression that would trap fails to execute
Soft Traps in LLDB
Soft trap mode must be enabled at build time — see build-settings.md for the compiler flag and Xcode build setting.
Supported OSs
The mode relies on an implementation of the __bounds_safety_soft_trap function being provided. On macOS/iOS 27.0 and newer this symbol is provided by libSystem and so this mode will work out-of-the-box. On older OSs this symbol is not provided and so linker errors will be observed. However, projects can provide their own implementation so that debugging is still possible. E.g.:
#include <bounds_safety_soft_traps.h>
__attribute__((noinline))
void __bounds_safety_soft_trap(void) {
// Provide a symbol for LLDB to set a breakpoint on but do nothing
}If projects do implement this function it must be removed when the project switched to hard trap mode.
Observing in LLDB
LLDB includes an instrumentation plugin that automatically stops on soft traps. When a soft trap is hit:
Process 779 stopped
* thread #1, stop reason = Soft Bounds check failed: indexing above upper bound in 'ptr[idx]'
frame #2: main`bad_read(ptr=(ptr: 0x00016af472a8, bounds: 0x00016af472a8..0x00016af472b4), idx=3) at main.c:4:62The backtrace shows:
- Frame 0:
__bounds_safety_soft_trap(the runtime function) - Frame 1: artificial frame with trap reason (
__clang_trap_msg$Bounds check failed$...) - Frame 2: the actual source location (LLDB selects this frame automatically)
(lldb) bt
frame #0: libsystem_sanitizers.dylib`__bounds_safety_soft_trap
frame #1: main`__clang_trap_msg$Bounds check failed$indexing above upper bound in 'ptr[idx]' [inlined]
* frame #2: main`bad_read(ptr=..., idx=3) at main.c:4:62
frame #3: main`main(argc=1, argv=...) at main.c:10:5Resume execution with c (continue), just like any other breakpoint.
Disabling the Soft Trap Plugin
Add to ~/.lldbinit:
plugin disable instrumentation-runtime.BoundsSafetyRestart your debugging session for this to take effect. Disabling mid-session is not currently supported.