safe-chains 0.221.0

Auto-allow safe bash commands in agentic coding tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
# The profile-resolution engine (Stage 4)

Status: design (2026-07-05). Stage 4 of the roadmap (v1.3 §6): run a level predicate
against a resolved behavior profile, behind a flag, alongside the legacy classifier.
Grounded in the current classifier; file:line anchors are approximate and for
orientation. Not yet implemented.

This changes **classification**, not **parsing** — the CST, its fold, and the
delegation re-entry points are reused verbatim; only the leaf verdict of a
*converted* command is computed a new way.

## 0. Safety by positive assertion — the fail-closed rule

A profile is a **conjunction of positive safety claims**, never a set of detected
hazards. This is the allowlist principle applied *per facet*: safety on an axis is
something the resolver must *earn* by understanding the command form, and any axis it
cannot affirmatively bound resolves to that facet's **worst term** — never to zero.

The distinction is the whole ballgame, and there is a one-line test for it:

> **A facet classifier is allowlist-valid iff its behavior on *unrecognized* input is
> the worst term.**

`classify_locus` passes — an unpinnable `$VAR`/`..` path resolves to `machine`, and only
*recognized* shapes earn a lower rung. A hypothetical `classify_secret(path)` that
returns `secret = none` for paths off a known-secret list **fails** — it certifies
everything unlisted as safe *by omission*, which is a denylist in a classifier's
clothes. The same trap exists on every axis (a "network-command detector" leaks the
novel network tool); the cure is uniform: **fail closed**.

Consequences that shape the resolver:

- **The `§2.8` "facets default to their zero term" is a data-model convenience the
  resolver must not lean on.** `network = none` has to be earned ("`echo` provably opens
  no socket"), not inherited from a struct default. The resolver's finalizer
  worst-cases every facet a command form did not positively claim — a converted command
  that forgets an axis fails *safe* (asks), never *open*.
- **There is no secret path-detector.** `secret` splits into (a) *credential-extraction
  intent*, a positive per-**command** claim (`security find-generic-password -w`,
  `gpg -d`, `ssh-add``reads`; `cat`/`echo``none``cat` extracts no credential,
  it is a byte-reader), and (b) *content exposure*, carried by `disclosure` (audience)
  + `locus`, both fail-closed. `cat ~/.ssh/id_rsa` is denied because it is a
  `user`-scope `content-to-model` read, **not** because `id_rsa` was recognized — so the
  unanticipated `cat ~/.config/newtool/token` is denied by the same bound, for free.
  The residual risk a level *does* accept (e.g. `cat ./.env` at a level that trusts
  worktree content-reads) is then a **named, located** property of that level, not a gap
  hidden in an incomplete list.
- **Converting a command means certifying all twelve axes**, positively — the research
  bar rises from "is it dangerous?" to "can I prove it is bounded on every axis?". That
  cost is the price of a trustworthy allowlist.
- **Enforceable, not aspirational:** every facet classifier must be
  *worst-case-dominant on unrecognized input* — a proptest (feed adversarial arguments,
  assert nothing resolves below worst-case without a positive match) is the gate each
  classifier passes. This strengthens §2.3's worst-case rule from *unresolved values* to
  *unaddressed facets*.

## 0.1 Proposed (not settled): the attested-typical basis

§0 is binary — a facet claim is either **structural** (proven from the command form →
auto-approvable) or **worst-case** (unresolved → ask). That over-denies a real third
case: a read safe in the normal case but not provably always. `ps aux` is the exemplar —
its cross-principal argv is secret-free ~99.9% of the time, but can carry a password
passed on another process's command line. (Note the risk there is *content*, not
"cross-principal" per se, and it is partly **structural**: argv-presence is
flag-determined, so plain `ps -o pid,comm` is `structural`-safe and only the
argv-requesting form is uncertain.)

**Proposal:** give each safety claim an explicit **basis**, and the residual it implies:

| basis | residual | example |
|---|---|---|
| `structural` | none — provable from the form | `echo "lit"` (no fs); `ps -o pid,comm` (no argv) |
| `attested` | small, **named** — researched typicality | `ps aux`: "typically secret-free; residual = secret-in-argv" |
| `worst-case` | unbounded — unresolved | a `$VAR` path; an unresearched command |

A **level declares a residual tolerance**: a strict level auto-approves only
`structural`; a pragmatic level accepts `attested` (opting into named residuals); nothing
auto-approves `worst-case`. The *same* `ps aux` is then auto at a residual-tolerant level
and ask at a strict one — for a stated, reviewable reason.

Two properties keep it allowlist-honest:
- The basis is always a **positive** claim — a structural proof, or a researched
  attestation with a *named* residual — and its absence falls to `worst-case`. This is
  **not** safe-by-omission; it is the opposite of the old "auto-run with a *may hold a
  secret* parenthetical," which hid the residual instead of naming it.
- **Attesting is separate from auto-approving.** The `basis`/`residual` is a *fact about
  the command* (researched, recorded in `because`/evidence), independent of whether any
  shipped level acts on it. We can attest "`ps aux` is typically safe" without a default
  level auto-approving on it.

**Boundary:** this is reputation-adjacent (`delegation` B.5 — the taxonomy classifies the
*mechanism*, not the identity), so it rides as an annotation **beside** the facets (a
per-claim `basis`), never smeared into a facet ordinal. The facets stay crisp; the
epistemics ride alongside.

**Status: proposed, not settled.** `echo` (the first resolver slice) is pure `structural`
and unaffected; `ps` would be the first client. Open questions: whether `basis` attaches
per-facet-claim or per-capability; how `residual` is represented (a free-text name vs a
small enum of residual kinds); and whether any *default* level accepts `attested`, or it
stays strictly opt-in.

## 0.2 Residuals of static analysis (named, not hidden)

The resolver is a **pure function of the command string** — it never touches the
filesystem (which would be TOCTOU-racy against a mutable tree anyway). Three limits
follow, and the honest posture is to name them:

- **Symlinks/hardlinks defeat `locus`.** `classify_locus` sees the literal path, so a
  worktree symlink `./link → /etc/shadow` classifies as `worktree` and is admitted at a
  worktree-trusting level, though it reads a machine-scope file. The `..` normalization
  escape *is* caught (→ worst-case); symbolic links cannot be, statically. A residual of
  path-based classification, accepted by levels that trust worktree content.
- **Command identity is name + path, never binary content (a deliberate scope boundary).**
  safe-chains classifies by a command's executable name and path; verifying that a binary
  is genuinely the tool it is named is *out of scope* — this is a string classifier, not
  an integrity checker (see `AGENTS.md` "Scope / trust model"). A bare `cat` trusts
  `$PATH` resolution to the real coreutils; a `$PATH` shadow is invisible. The resolver
  *does* worst-case a resolvable name reached via a non-standard path (`./cat`,
  `/tmp/cat`, `~/bin/grep`) — closing the planted-binary case — but a `$PATH`-shadowed
  bare `cat` is inherently trusted. (The legacy classifier keys purely on basename and
  hardens neither.)
- **TOCTOU.** Any static verdict can be invalidated by a filesystem change between check
  and exec. safe-chains is an allowlist gate, not a sandbox; it bounds what it *certifies*,
  not what the OS *enforces*.

These are the boundary of what a string-only, side-effect-free resolver can promise;
where a level accepts one, it is a stated property of that level.

## 1. Where the engine attaches

Today a command line is folded to a `Verdict` bottom-up: `command_verdict` →
`script_verdict` → `pipeline_verdict` → `cmd_verdict` → `simple_verdict`
(`src/cst/check.rs`), combining per-segment verdicts with `Verdict::combine` —
max-level, deny-absorbing (`src/verdict.rs`). The single leaf act is
`simple_verdict`'s call to `handlers::dispatch(&tokens)` (`src/cst/check.rs`),
which walks the handler chain and ends in `registry::toml_dispatch` →
`dispatch_spec` → `dispatch_kind` (`src/registry/dispatch.rs`), yielding
`Allowed(level)` or `Denied`.

The engine replaces exactly that leaf call, and nothing above it. For a converted
command, `simple_verdict` resolves a **behavior profile** and evaluates the active
level's predicate, projecting the result back to a `Verdict` so the existing fold,
redirect handling, substitution recursion, and chain semantics are untouched.
Redirects are the one leaf act that is *not* a command lookup —
`redirect_verdict`/`is_safe_write_target` (`src/cst/check.rs`) — and they become an
intrinsic capability on the enclosing command's profile (§2.3), not a separate
combine.

Two facets of the model do not fit a per-`Cmd` fold and run as a second pass at
`Script` scope (§3.1): the **flow policy** (a `secret`→outbound edge crosses a pipe;
`CmdSub`/`ProcSub` and the `Pipeline.commands` vector are the edges) and
**session-derived worst-casing**. Because both can only *deny* an otherwise-admitted
line, they compose with the deny-absorbing fold as additional deny sources — they
never rescue a denied segment.

## 2. Profile resolution

A resolved command line becomes a **profile**: a set of `capability` records (v1.3
§2.8). Capabilities attach to grammar nodes, classified by their effect on
facet-space:

- **additive** — contributes a capability (a leaf act: a `Policy` sub, a redirect
  write). The profile is the union.
- **modifier** — transforms the facets of capabilities already in scope, applied in a
  pinned order (§2.4). Never adds an operation of its own except the injection-point
  class (§3.5 of v1.3), which is additive-by-modifier.
- **gating** — narrows what a nested resolution may contribute (an isolation frame
  clamps nested `locus` to `sandbox-scope`; a `--` separator bounds where delegation
  begins).

### 2.1 Mapping the DispatchKind tree onto nodes

The existing `DispatchKind` tree (`src/registry/types.rs`) is already the node
structure; conversion re-labels each arm as a profile contribution rather than a
level literal:

| DispatchKind | node role | profile contribution |
|---|---|---|
| `Policy{level}` | additive leaf | one capability; its facets are the converted form of what `level` summarized |
| `FirstArg`, `RequireAny` | additive leaf, guarded | same, admitted only when the guard/pattern matches |
| `Branching{subs}` | interior node | recurse into the matched `SubSpec`; each sub is its own subtree of nodes |
| `WriteFlagged{write_flags}` | leaf + modifier | base capability; presence of a write flag is the `--force`-class modifier that raises `reversibility`/`persistence` |
| `Wrapper`, `DelegateAfterSeparator`, `DelegateSkip` | delegation frame | a **frame** (§2.2) over the nested profile obtained by re-entering the resolver on `tokens[i..]` |
| `Custom{handler}` | opaque or sub-forest | handler-decided; if it consults `try_sub_dispatch`/`try_fallback_grammar` the subs/fallback are ordinary nodes, else the profile is opaque → worst-case |

The `SafetyLevel` a leaf carries today (`Policy.level`, `first_arg_level`, sub
`level`) is the pre-conversion summary; converting the command replaces each with the
explicit facet vector it stood for. This is the only data edit conversion requires
(§7).

### 2.2 Delegation re-enters the resolver

`Wrapper`/`Delegate*` already reconstruct the inner command line and call
`command_verdict` recursively (`src/registry/dispatch.rs`). Under the engine that call
returns a nested **profile**, and the frame transforms it before union (v1.3 §3.1):

- `transparent` (`bash -c`, `env`, `xargs`) — identity; `env` with `LD_PRELOAD`/`PATH`
  adds `reconfiguring`.
- `privilege` (`sudo`, `doas`) — nested `authority := elevated/root`.
- `remote` (`ssh h CMD`, `docker exec`) — nested `locus.remote`; intrinsic outbound.
- `isolation` (`docker run`, `bwrap`) — clamp nested `locus.local` to
  `sandbox-scope`, re-add breach loci.
- `payload` (`psql -c`, `kubectl apply -f`) — nested is a typed document, decomposed
  by the four gates and resolution ladder (§3.1).
- `task-runner` (`make`, `npm run`) — nested is `ambient-config`, opaque unless a
  trusted-config root.
- `interactive` (bare `ssh`, a REPL) — nested is future input, unavailable → opaque,
  unbounded.

Frames compose (`sudo ssh h CMD` = privilege ∘ remote); nesting is shallow so
resolution runs to completion, and an unparseable inner command is opaque →
worst-case. Cross-segment compounding (`a && b | c`) is **not** delegation — the
`Script`/`Pipeline` fold already handles it; fan-out (`xargs`, `find -exec`) is the
`scale` facet, not a frame.

### 2.3 Argument resolution and worst-case

Locus, disclosure audience, network destination, and scale are functions of argument
*values*, resolved by **fail-closed** classifiers (§0) that generalize the predicates
the current code already has (`secret` is *not* here — it is credential-extraction
intent, a positive per-*command* claim, §0):
`is_safe_write_target` (`src/cst/check.rs`) becomes `classify_locus`
over a write target (the same `/tmp`, `.git/`, `.envrc`, `$`-bearing, parent-escape
distinctions become `local` ordinal rungs); `PositionalShape`/`looks_like_path`
(`src/policy.rs`) becomes a shape input to the same classifier; `consumes_next_value`
(`src/policy.rs`) is unchanged — it is grammar, not facet, and still decides which
token is an argument. Operand roles (source vs sink for `rsync`/`scp`) flip the
dominant facet per v1.3 §3.3.

**Rule (§0, at the value level):** any value that cannot be pinned — `$VAR`, glob,
stdin, a substitution — takes the facet's worst term, and the assumption is recorded in
`because`. This is the profile-level statement of the allowlist floor already enforced
by denying unresolved substitutions (`src/cst/check.rs`), and the same fail-closed
posture §0 extends from unresolved *values* to unaddressed *facets*.

### 2.4 Modifier order

Modifiers must apply in a pinned order so the profile is a function of the form, not
of token order (v1.3 §3.5): `--dry-run` (→ all `observe`) first, then retargeting
(`-o FILE`/redirect → locus), then severity raisers (`--force` → `irreversible`, `-R`
→ `unbounded`), then injection-point flags. `--dry-run` dominating first is what makes
`terraform plan --destroy` observe-only regardless of the `--destroy` token.

## 3. Level evaluation

A level is a disjunction of **clauses**; each clause is a conjunction of per-facet
constraints plus a flow policy (v1.3 §4, §4.1). The TOML primitives are
`facet = "<= term"` (ordinal ceiling), `facet = ["a","b"]` (categorical set), and
`extends = "other"` (inherit clauses, then add).

Evaluation:

1. A **capability is admissible** iff some clause admits *every* one of its facets
   (ordinal ≤ ceiling; categorical ∈ set; compound facets checked axis-by-axis, see
   Findings). Clauses are tried in order; first admitting clause wins.
2. A **profile passes** iff every capability is admissible **and** the `Script`-scope
   flow policy holds.
3. The passing/failing profile is projected to a `Verdict`: pass → `Allowed(L')` where
   `L'` is the legacy-lattice projection of the profile (Stage-3 bridge run backward,
   for the reported level string only); fail → `Denied`. The gate is "profile passes
   active level," not the projected ordinal.

`extends` is clause-set union: `developer` inherits `write-local`'s clauses and adds
its supply-chain and outbound carve-ins (v1.3 §4.1). Compiling a level is the same
shape as `build_command` compiling TOML to a `DispatchKind` (`src/registry/build.rs`)
— a new `build_level` emitting a `Level` value of `Vec<Clause>`.

### 3.1 Resolution depth makes it lazy

The predicate names the resolution it needs (v1.3 §4.4). Facets are resolved
**lazily**: a level whose clauses never reference `execution.supply_chain` does not
descend a payload body; a level that ceilings `payload = forbid` decides at *presence*
of the raw hatch and never parses it. Unavailable resolution (no parser for the nested
language, an unpinnable argument) is worst-case → deny. This is the guarantee that the
engine need not become a universal interpreter: the deep case (a k8s `apply -f` body,
gate 4 — `behavioral-taxonomy-k8s-resolver`) is built once, per `(language, level)`,
and every level that does not demand it stops shallow.

## 4. Coexistence dispatch (v1.3 §4.5)

The converted-vs-legacy switch lives at the *top-level command lookup*, i.e. just
inside `simple_verdict` where `handlers::dispatch` is called today
(`src/cst/check.rs`), keyed on the command name exactly as `toml_dispatch` keys the
registry (`src/registry/mod.rs`):

- **Converted** (TOML carries a behavior profile) → resolve the profile (§2) and
  evaluate the active level (§3).
- **Legacy** (TOML carries only `Inert`/`SafeRead`/`SafeWrite`) → run the existing
  classifier unchanged, then map the legacy level onto the lattice via the Stage-3
  bridge — `Inert→inert`, `SafeRead→read-local`, `SafeWrite→developer` — and admit iff
  the mapped level lies within the active level.
- **Unclassified** (neither) → `Denied`, as today. The floor is unchanged.

A command's TOML signals converted-vs-legacy by the presence of profile data: the
natural encoding is a `[[command.profile]]`/`profile = "…"` presence check on
`TomlCommand` (mirroring how `handler`, `wrapper`, `deny` already select a build path
in `build_command`, `src/registry/build.rs`). Conversion is **atomic at the tool
subtree** (v1.3 §4.5): a command is converted only when every sub/flag/arg-shape under
it has profile data, enforced at build time by a `build_command` assertion (the same
class as the existing flat-vs-structured assertion) — no half-converted tool compiles.

Because the shipped default level is `developer` (≈ the old `SafeWrite` threshold),
converted and legacy commands auto-approve identically until a user opts into a
stricter level; the machinery is invisible until adopted.

## 5. The Stage-4 rollout harness

"Behind a flag" is a three-state selector — env var
`SAFE_CHAINS_ENGINE={legacy|shadow|new}` (or the equivalent CLI flag on the check
subcommand):

- `legacy` (default) — the current classifier is authoritative; the engine does not
  run.
- `shadow` — the legacy classifier is authoritative for the returned verdict, and the
  engine runs alongside, per level, recording old-vs-new divergences without affecting
  the decision.
- `new` — the engine is authoritative.

The corpus diff is a test/CI harness, not a runtime path: for each form in the
golden-set (v1.3 §5, the 65-form seed) and each command's `examples_safe` /
`examples_denied` (`src/registry/types.rs`), and for each shipped level, compute
`legacy_verdict` and `engine_verdict` and classify the divergence:

- **intended-tightening** — engine denies what legacy allowed, and the level's design
  says so (a `read-local` user holding out a `SafeWrite` tool). Logged, expected.
- **unacceptable-breakage** — engine denies what legacy allowed at the *default* level
  (`developer`), or allows what legacy denied. A gate; the build fails.

The harness is the operational form of v1.3 §4.5's "conversion is monotone and
reviewable": no command is worse off than its legacy behavior at the default level by
accident.

## 6. The proptest surface (v1.3 §4.2)

Represent the profile as one Rust enum per facet (ordinals as
`#[derive(PartialOrd, Ord)]` C-like enums, exactly as `SafetyLevel` `src/verdict.rs`;
categoricals as plain enums; compound facets as structs of their axes).
`Clause`/`Level` are `Vec`s of per-facet constraints. The existing proptest
infrastructure (type-directed `Arbitrary` strategies for words/scripts) is the model
for facet generation — add `Arbitrary` for `Capability` and `Profile`, and the
CST-refactor direction (type-directed generation over the parsed tree) supplies the
end-to-end generator.

The five contract properties become concrete tests:

- **facet-monotonicity** — for any admitted `Profile` and any single facet made *less*
  severe (ordinal down one rung / a categorical swapped for an admitted one), the
  profile stays admitted. A level failing this is incoherent by construction; the test
  is the coherence check on hand-authored level TOML.
- **extends ⇒ superset**`admits(child, p)``admits(parent-extended, p)` for every
  generated `p`, over all `extends` pairs in the level set.
- **totality** — every generated `Profile` yields a decision (admit or deny), never a
  panic or "unknown"; the worst-case rule guarantees a total function.
- **round-trip**`Level` → TOML → `Level` is identity, so the compiled artifact
  matches the source.
- **golden-set anchor** — the frozen 65-form corpus resolves to its recorded facet
  vectors; any taxonomy or engine change must keep it classifiable and re-review the
  diff (v1.3 §5).

## 7. Migration mechanics

Converting one command, end to end:

1. Replace the command's `level`/`DispatchKind` *data* with behavior-profile data in
   its `commands/*.toml` — each leaf's `level` becomes an explicit facet vector;
   delegating nodes declare their frame; argument-shaped facets declare their
   classifier. The grammar (subs, flags, `consumes_next_value`, positional shapes) is
   unchanged — it still decides *which tokens are what*.
2. The build-time atomicity assertion (§4) confirms the whole subtree carries profile
   data; the command flips to the engine automatically on next build — there is no
   registration step, matching the auto-discovery `build.rs` already does for TOML
   files.
3. Run the Stage-4 harness (§5): review the per-level golden-set diff, label each
   divergence, gate on zero unacceptable-breakage.

The load-bearing point: **the parsed CST is shared**. `command_verdict` parses once;
the `DispatchKind` walk and the profile resolver consume the same `tokens`/CST.
Conversion swaps the *classification* function at one leaf; every line above it (chain
semantics, substitution recursion, redirect handling, `eval` safety) is identical for
converted and legacy commands. This is what lets the corpus be a mix of the two for as
long as migration takes (v1.3 §6, Stage 5).

## Findings / risks

- **Compound facets are the representational sharp edge.** `locus{local,remote}` and
  `persistence{level, trigger{escape,kind}}` are not single ordinals, so admissibility
  is per-axis and monotonicity must hold *per axis* independently — a naive
  `derive(Ord)` on a compound struct will silently order `kernel` vs `remote` (the
  exact bug R25 fixed in the taxonomy). Model each axis as its own enum and write
  `admits` axis-by-axis; do **not** collapse a compound facet to one ordinal for
  convenience.
- **Modifier order-independence is a proptest liability.** §2.4 pins an order, but
  facet-monotonicity generation must not assume a canonical token order — generate
  profiles directly (post-modifier), not command strings, for the algebra tests, and
  test the modifier pipeline separately with a small hand-built set of `(--dry-run,
  --force, -R, redirect)` combinations to prove order-invariance of the *result*.
- **Shadow-compare cost is per-level × per-form.** Running the engine for every shipped
  level over the whole corpus on every CI run is O(levels × forms × resolution depth).
  Keep it a dedicated corpus-diff test, not part of the hot `command_verdict` path, and
  cache resolved profiles (profile resolution is independent of the level).
- **The projected verdict level is a lie of convenience.** Emitting `Allowed(L')` via
  the backward Stage-3 bridge keeps the fold and the reported level string working, but
  that ordinal is not the authoritative decision. Ensure nothing downstream (docs,
  `targets/*.rs` `additionalContext`) treats the projected `SafetyLevel` of a converted
  command as meaningful beyond display.
- **Flow policy escapes the leaf fold.** Because `secret`→outbound crosses pipe and
  substitution edges, it cannot be decided inside `simple_verdict`; it needs a
  `Script`-scope pass with access to the CST edges. Risk: getting it wrong *silently
  widens* (a missed edge admits an exfil). Ship it deny-only and default-conservative —
  an unrecognized channel is an outbound sink (v1.3 §2.5).

**First slice.** Convert **two flat, high-signal commands** that exercise the whole
pipeline without a deep grammar: (1) a pure-`Policy` read tool such as `grep` or `cat`
— additive leaf, argument-resolved `locus`/`disclosure`, no delegation, no modifiers;
it proves profile resolution, the `read-local` level, and the coexistence switch. (2)
`rm` (or a `WriteFlagged` command) — exercises the `--force`/`-R` modifier pipeline,
`reversibility`/`scale` worst-casing on globs and `$VAR`, and an `intended-tightening`
at `read-local`. Together they cover additive + modifier + argument-resolution + both
bridge directions with zero payload/flow depth, so the Stage-4 harness and the five
proptest properties can be stood up and proven green before any delegating or payload
command is touched.