Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
c50cde3
fix(safety): bind the leaf where expecting: is nil — contents mode an…
acebytes Aug 27, 2026
28eb583
fix(safety): a drain that died on a hard read error no longer ends li…
acebytes Aug 27, 2026
7f2ac6e
fix(safety): the gate answers before realpath — pointer chase and pre…
acebytes Aug 27, 2026
820a0a7
fix(docs): repoint the mount-arm anchor the fn-4.26 insertion shifted…
acebytes Aug 27, 2026
6bb24c0
fix(docs): repoint the two CacheCleaner anchors fn-4.21's growth shif…
acebytes Aug 27, 2026
a8de2a2
test(safety): evidence the non-ENOENT bind-failure branch of the pipe…
acebytes Aug 27, 2026
3c98463
fix(safety): bound the container-identity capture off the calling thr…
acebytes Aug 27, 2026
2e21091
fix(safety): bound dockerPrune's whole child interaction; retire the …
acebytes Aug 27, 2026
80e5d61
fix(docs): repoint the 11 anchors fn-4.19's growth shifted (fn-4.19)
acebytes Aug 27, 2026
9ce6b1d
fix(safety): the sizing walk answers cancellation and bounds its entr…
acebytes Aug 27, 2026
a4670b0
fix(safety): sweep sync fs-tree recursion on actor-reached paths; cla…
acebytes Aug 27, 2026
a4cd9ce
fix(docs): repoint the walker anchor fn-4.13's ceiling shifted (fn-4.13)
acebytes Aug 27, 2026
fbb1840
fix(safety): no realpath of a symlink root's destination at construct…
acebytes Aug 27, 2026
5c36346
fix(docs): the rotting CacheCleaner anchors were invisible to the gat…
acebytes Aug 27, 2026
2335a54
fix(tests): frame the socket reply read; fence as!/fatalError/precond…
acebytes Aug 27, 2026
62336c1
fix(tests): the climb census cell measured a process-wide count and a…
acebytes Aug 27, 2026
e7560fd
test(safety): pin every performer refusal tag to its arm — full per-t…
acebytes Aug 27, 2026
f8aa99f
fix(safety): every issue-kind label states its producer's true condit…
acebytes Aug 27, 2026
5a497ea
test(safety): evidence the sweep root's non-directory arm at scan lev…
acebytes Aug 27, 2026
8f02d35
test(safety): evidence the walker's open-failure EPERM arm — it had n…
acebytes Aug 27, 2026
d6a5bd7
fix(safety): the git child's process group is established AT SPAWN, n…
acebytes Aug 27, 2026
66c8e71
fix(safety): the containment descent's own raw-errno classifier joins…
acebytes Aug 27, 2026
3d64136
fix(scanner): discover the bare repository whose checkouts are all go…
acebytes Aug 27, 2026
c8efba0
test(scanner): fn-4.18 measured — the dev-root double walk is 2% dupl…
acebytes Aug 27, 2026
f00229c
fix(safety): pin the message vocabulary byte-exact — Remedy loses its…
acebytes Aug 27, 2026
efc2760
test(safety): fn-4.22 attack round recorded — 0 of 11 fresh false wor…
acebytes Aug 27, 2026
83e2fd7
fix(safety): a losing task must never launch the prune it was already…
acebytes Aug 28, 2026
150fe01
fix(safety): MOUNT FIRST, and now actually first (PR #461 codex r1, P2)
acebytes Aug 28, 2026
2bb2596
fix(safety): an unchecked spawn setup does not lose an error, it spaw…
acebytes Aug 28, 2026
36dff04
fix(safety): the claim now PERFORMS the launch — 83e2fd7 moved the wi…
acebytes Aug 28, 2026
f4575bf
fix(safety): MOUNT FIRST means before the TCC gate too — and the cell…
acebytes Aug 28, 2026
28a8f15
test(safety): the spawn fence was a blocklist in both directions — re…
acebytes Aug 28, 2026
71133b2
fix(test): the LaunchClaim cells proved less than their names (gate r…
acebytes Aug 28, 2026
7d7cb60
fix(test): the spawn fence guarded calls, so an alias walked past it …
acebytes Aug 28, 2026
d0a0b66
docs(residual): disclose the three the gate found undisclosed (r2 P5,…
acebytes Aug 28, 2026
1913dbc
fix(scanner): bareness is core.bare's EFFECTIVE value, not any `bare`…
acebytes Aug 28, 2026
1a3bb64
fix(docs): retire a false disclosure, qualify an invariant at its own…
acebytes Aug 28, 2026
228ce49
fix(docs): "the hang is impossible" was false for the ordinary root (…
acebytes Aug 28, 2026
2d5b351
fix(spawn): check every strdup, and evidence the spawn's own guard (c…
acebytes Aug 28, 2026
0392a6c
fix(test): the fence guarded one function's body, so both aliases hoi…
acebytes Aug 28, 2026
7261d72
fix(prune): the launch claim was closed in the type and unlatched at …
acebytes Aug 28, 2026
432441b
fix(read): one no-follow bounded descriptor for every git metadata re…
acebytes Aug 28, 2026
70f4376
fix(cleaner): item mode measured, then bound — contents mode has alwa…
acebytes Aug 28, 2026
c57a0d7
fix(cleaner): a stranger arriving at a GHOST target was deleted and r…
acebytes Aug 28, 2026
9c06109
fix(spawn): the strdup defect was still live one file out, and the fe…
acebytes Aug 29, 2026
4ececeb
fix(docs): a rotted anchor, a false limit disclosure, and two evidenc…
acebytes Aug 29, 2026
89ec164
test(read): the eight converted read sites had no cell at all (gate r…
acebytes Aug 29, 2026
21c48b5
fix(test): the fence's new lexer tripped the strand fence, so it no l…
acebytes Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,43 @@ below are both part of that coordination, and the latter BLOCKS this release.

### Fixed

- **Every scan-issue row now states a condition that is true for the
producer that emitted it** (fn-4.12 — the PR #459 codex r13 sweep, run
over every OTHER scanner's producers; the app's visible row label is
derived from `scanner_errors[].kind` alone, so a kind shared with a
different condition prints a false diagnosis). All ADDITIONS to the same
extensible enumeration (`schema_version` stays 4); no wire STRING is
renamed, but the kind a given condition reports under moves, so consumers
keying kinds to conditions must re-key: **(1)** a configured dev root
refused by the search-root safety policy — persisted, or via `--dev-root`
— is now `"policy_refused_root"` ("refused by the search-root safety
policy"); it was `"container_refused"`, whose label "not a configured
search root" contradicted the row's own detail ("configured dev root
refused: …"). **(2)** a configured dev root with a volume mounted exactly
at it is now `"mounted_volume_root"`, whose label names the one remedy a
re-scan honors (the walk re-reads the kernel mount table every scan).
**(3)** a dev root or `~/Library/Caches` sweep root standing as a regular
file, FIFO, socket or device is now `"non_directory_root"`; it was
`"symlink_root"`, which sent the user hunting for a link that was not
there — `"symlink_root"` now means a symlink and nothing else, in every
scanner. **(4)** a git worktree or repository admin directory withheld
because git's cleanup would modify paths not all inside ONE configured
dev root is the NEW `"mutation_scope_refused"` ("git cleanup is not
contained in one dev root — not offered"); it was `"container_refused"`
while the worktree in question IS inside a configured root. A worktree
outside EVERY configured root keeps `"container_refused"` — there the
label is exactly the condition. **(5)** a BARE-errno EPERM (raw
`lstat`/`open` probes) is now neutral `"unreadable"` everywhere, with the
detail saying the cause could not be established; it was `"tcc_denied"`
in the dev-root walk and the orphaned-caches sweep — printing the "Grant
access…" (Full Disk Access) remedy on a guess — while the temp scanner
already classified the same errno as unknowable (a bare errno carries no
provenance; TCC, SIP and other filesystem refusals are indistinguishable
in it). A chain-proven EPERM — recovered from a Cocoa error's
`NSUnderlyingErrorKey` chain — still reports `"tcc_denied"` with the
grant hint, which is the one place the claim is establishable. The two
scanners that disagreed on bare EPERM now share one recorded rule
(`DirectorySizer.denial(forFailedProbe:)`).
- **A scan could hang before it started, with the spinner up and no way to
stop it.** The first thing a scan does after marking itself in progress is
refresh the free-space figures in the header. That refresh was unbounded:
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ CacheCategory(
`--confirm` — preview safely with `--cli clean <slug> --dry-run`)
5. Submit a PR with a clear description

> **A green tally is not a green run.** A trapping construct (`as!`, `try!`,
> a force-unwrap, an out-of-range subscript) kills the whole test process,
> and every `Executed N tests … 0 failures` line printed BEFORE the kill
> stays in the log — one truncated run showed a passing tally while ~26
> later suites never executed (fn-4.14). When reading a `swift test` log,
> trust only the process EXIT CODE and the final executed COUNT compared
> against the expected baseline, never a greppable `0 failures` line.
> `StrandFenceTests` fences the trapping shapes out of test sources.

## Documentation

Full technical documentation is in [docs/v1/](docs/v1/):
Expand Down
43 changes: 30 additions & 13 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,8 @@ refreshes.
"scanner_errors": [
{
"scanner_id": "build_artifacts",
"kind": "container_refused",
"detail": "dev root is not a usable container: the filesystem root",
"kind": "policy_refused_root",
"detail": "configured dev root refused: the filesystem root",
"path": "/"
},
{
Expand Down Expand Up @@ -292,7 +292,7 @@ refreshes.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `scanner_id` | string | yes | Which scanner reported (or failed validation) |
| `kind` | string | yes | One of: `"container_refused"`, `"mounted_volume_root"`, `"mounted_volume_root_at_registration"`, `"policy_refused_root"`, `"symlink_root"`, `"non_directory_root"`, `"tcc_denied"`, `"permission_denied"`, `"unreadable"`, `"enumeration_truncated"`, `"config_invalid"`, `"tool_unavailable"`, `"malformed_outcome"`, `"scan_did_not_finish"`. The list is EXTENSIBLE — consumers must tolerate unknown kinds |
| `kind` | string | yes | One of: `"container_refused"`, `"mounted_volume_root"`, `"mounted_volume_root_at_registration"`, `"policy_refused_root"`, `"mutation_scope_refused"`, `"symlink_root"`, `"non_directory_root"`, `"tcc_denied"`, `"permission_denied"`, `"unreadable"`, `"enumeration_truncated"`, `"config_invalid"`, `"tool_unavailable"`, `"malformed_outcome"`, `"scan_did_not_finish"`. The list is EXTENSIBLE — consumers must tolerate unknown kinds |
| `detail` | string | yes | Human-readable description |
| `path` | string | conditional | Present for the FILESYSTEM kinds; ABSENT for the NON-FILESYSTEM kinds — `"malformed_outcome"`, `"config_invalid"`, `"tool_unavailable"` and `"scan_did_not_finish"` — where no filesystem location exists and a fake path is therefore never invented |
| `grant_hint` | string | no | Present only when `kind == "tcc_denied"` — the same user-side remedy (Full Disk Access) as category and `scanner_items` rows, since macOS denies CLI processes silently |
Expand All @@ -309,16 +309,18 @@ scan outcome while the corrupt value persists — the fallback is never
silent. It carries no `path` because a config parse failure has no honest
filesystem location. A configured root that was REJECTED by policy (the
filesystem root, a volume root/mount point, `$HOME`) is a different thing
and reports honestly WITH its offending path, under `container_refused` for
the dev-root scanners and under `policy_refused_root` for `ephemeral_tmp`.
and reports honestly WITH its offending path, under `policy_refused_root`
for every scanner that resolves configured roots (dev-root scanners since
fn-4.12; `ephemeral_tmp` since PR #459).

A `mounted_volume_root` row means a REGISTERED root has another volume
mounted exactly at its path, so whatever is there belongs to that volume
rather than to the root. It is deliberately NOT `container_refused`: the
root is configured and admissible, nothing rejected it, and the condition is
one the user clears — eject or unmount the volume, then re-scan. Emitted
today by `ephemeral_tmp`, which answers from the kernel's mount table before
any syscall touches the root.
today by `ephemeral_tmp` and (since fn-4.12) by the dev-root walk behind
`build_artifacts`/`git_worktrees`; each answers from the kernel's mount
table, re-read every scan, before any syscall touches the root.

A `mounted_volume_root_at_registration` row means the same condition was
already true when the runtime was CONSTRUCTED, so that root was never
Expand All @@ -336,18 +338,33 @@ deliberately NOT `container_refused`: a scanner builds its guard from its
own roots, so a root that reaches this refusal was configured, and
`container_refused` reads as "you did not configure this". `detail` names
the clause that fired; there is no single remedy across the clauses.
Emitted today by `ephemeral_tmp`, whose roots (`/private/tmp` and the two
per-user `confstr` containers) are not user-configurable at all.
Emitted today by `ephemeral_tmp` (whose roots — `/private/tmp` and the two
per-user `confstr` containers — are not user-configurable at all) and,
since fn-4.12, by dev-root resolution and the dev-root walk behind
`build_artifacts`/`git_worktrees`, which previously spelled the same
refusals `container_refused` against their own contradicting details.

A `mutation_scope_refused` row means a DISCOVERED deletable candidate (a
git worktree, or a repository's orphaned worktree admin data) was withheld
because the destructive git operation's whole mutation scope — the paths
git itself would modify plus the parent repository whose records name them
— is not contained in ONE configured dev root. The candidate itself is
often INSIDE a configured root, which is why this is deliberately NOT
`container_refused`: that kind's fixed row label ("not a configured search
root") was a false diagnosis for these producers. `path` names the withheld
candidate; `detail` names which path broke the containment. No remedy is
claimed — where the out-of-scope data sits is the user's layout. Emitted
today by `git_worktrees` (fn-4.12).

A `non_directory_root` row means a search root EXISTS and is not a symlink,
but is not a directory either — a regular file, FIFO, socket or device
stands where a directory is required, and nothing was traversed. It is
deliberately NOT `symlink_root`: that kind renders as the fixed sentence
"symlinked — not searched", which sends the user hunting for a link that is
not there. `detail` names the object's actual kind. Emitted today by
`ephemeral_tmp`, whose `symlink_root` is therefore now a symlink and
nothing else; the other scanners still spell both conditions
`symlink_root`.
not there. `detail` names the object's actual kind. Emitted by every scanner with a
root gate (`ephemeral_tmp` since PR #459; the dev-root walk,
`orphaned_caches` and dev-root resolution since fn-4.12), so `symlink_root`
now means a symlink and nothing else, everywhere it is emitted.

A `tool_unavailable` row means the scan could not run an external tool it
depends on, so it produced NO results — today: `git_worktrees` could not
Expand Down
35 changes: 31 additions & 4 deletions Sources/Cacheout/CLIHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ struct CLIHandler {
context: ScanContext
) async -> CollectedScanEvents {
var collected = CollectedScanEvents()
let session = runtime.scanValidatedSession(
let session = await runtime.scanValidatedSession(
scannerIDs: scannerIDs, context: context
)
collected.snapshot = session.snapshot
Expand Down Expand Up @@ -1140,8 +1140,10 @@ struct CLIHandler {
// The policy's own verdict, surfaced as a usage error (the CLI
// attack case: `--dev-root /`). `.configInvalid` cannot occur on the
// replacement path — nothing was parsed out of the defaults suite.
// `.policyRefusedRoot` since fn-4.12 — the resolution pipeline's
// refusal kind for a CONFIGURED (here: flag-declared) root.
if let refused = resolution.issues.first(
where: { $0.kind == .containerRefused }
where: { $0.kind == .policyRefusedRoot }
) {
return .failure(CLIAddressError(message:
"\(devRootFlag) \(refused.url?.path ?? "") is not a usable "
Expand Down Expand Up @@ -2896,8 +2898,33 @@ struct CLIHandler {

// Replace the process image; argv[0] becomes the resolved path so the
// re-exec'd process' Bundle.main is the real app bundle.
var argv: [UnsafeMutablePointer<CChar>?] = CommandLine.arguments.map { strdup($0) }
argv[0] = strdup(resolved)
//
// NIL IS argv's TERMINATOR, SO A FAILED COPY IS NOT A LOST ARGUMENT —
// IT IS A DIFFERENT COMMAND (PR #461 merge gate r4, P1). This is the
// same defect the spawn path carried, in the sibling exec path, and
// the fence that now guards `GitCommandRunner` could not see it
// because that fence reads one file. `map { strdup($0) }` wrote a
// failed allocation's nil straight into the vector: a failed copy of
// element k truncates the command there, so `cacheout install-helper`
// invoked through the documented Homebrew symlink re-execs into a
// no-subcommand `cacheout` — and because the process image is already
// replaced by then, nothing can report it.
//
// The `defer` is registered BEFORE the vector is filled, so a failure
// part-way frees what was already copied; on a successful `execv` it
// never runs, because there is no longer a process to run it in.
var argv: [UnsafeMutablePointer<CChar>?] = []
defer { argv.forEach { free($0) } }
for text in [resolved] + CommandLine.arguments.dropFirst() {
guard let copy = strdup(text) else {
printError(
"Warning: could not re-exec bundled binary at \(resolved): "
+ "out of memory copying arguments — not re-exec'd"
)
return
}
argv.append(copy)
}
argv.append(nil)
execv(resolved, argv)
// execv only returns on failure — continue and let SMAppService report.
Expand Down
Loading
Loading