-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsluice
More file actions
executable file
·4183 lines (3944 loc) · 242 KB
/
Copy pathsluice
File metadata and controls
executable file
·4183 lines (3944 loc) · 242 KB
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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
# sluice - run any project in a sandboxed, egress-firewalled container.
# Drop a sluice.config.sh in a project (or run `sluice` to scaffold one); `sluice help` lists the
# commands. Per-project image/container, auto-rebuilt when the config or core changes.
#
# GENERATED FILE - do not edit directly. Assembled from src/*.sh by `make build`
# (the slices concatenate in order; a CI gate fails if this file drifts from src/).
set -euo pipefail
# resolve our install dir (bin/sluice is symlinked onto PATH; follow the chain)
SELF="$0"
while [ -h "$SELF" ]; do
link="$(readlink "$SELF")"
case "$link" in /*) SELF="$link";; *) SELF="$(dirname "$SELF")/$link";; esac
done
ROOT="$(cd "$(dirname "$SELF")/.." && pwd)"
CORE="$ROOT/core"
die() { echo "${E_RED:-}[sluice]${E_RST:-} $*" >&2; exit 1; }
# minimal JSON emit (host jq is not assumed; fields here are short/flat)
# Escape a string for a JSON value: backslash + doublequote, flatten tab/newline, then DELETE every
# remaining C0 control byte + DEL (ESC/BEL/OSC) so a box-controlled value (e.g. a logged SNI) can't
# smuggle a terminal-escape sequence through `--json`/persisted receipts when they're later cat'd.
_json_esc() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/}"; printf '%s' "$s" | LC_ALL=C tr -d '\000-\037\177'; }
# Sanitize a box-controlled string for safe display on a TERMINAL: flatten whitespace and DELETE every
# C0 control byte + DEL (ESC/BEL/OSC). Unlike _json_esc it leaves \ and " intact (this is for humans, not
# JSON), so a crafted filename / symlink target can't inject escapes or forge a line of `doctor` output.
_term_esc() { local s="$1"; s="${s//$'\t'/ }"; s="${s//$'\n'/ }"; s="${s//$'\r'/ }"; printf '%s' "$s" | LC_ALL=C tr -d '\000-\037\177'; }
# Emit a JSON array of strings from newline-separated stdin (blank lines skipped; a final line
# without a trailing newline still counts - base_domains emits one).
_json_arr() { local first=1 line; printf '['; while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] || continue; [ "$first" = 1 ] && first=0 || printf ','; printf '"%s"' "$(_json_esc "$line")"; done; printf ']'; }
# Home-relative display form of a path (~/...), for HUMAN output only - ls already renders paths
# this way; doctor/learn share it via this helper. JSON output keeps raw absolute paths.
_tilde() { case "$1" in "$HOME"/*) printf '~%s' "${1#"$HOME"}";; "$HOME") printf '~';; *) printf '%s' "$1";; esac; }
# color: gated on a stdout TTY + NO_COLOR, so piped/redirected output stays plain ASCII
# (the --json paths print no color regardless; the TTY gate also blanks these when piped.)
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
C_GRN=$'\033[32m'; C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'; C_BLD=$'\033[1m'; C_RST=$'\033[0m'
else
C_GRN=''; C_RED=''; C_YEL=''; C_DIM=''; C_BLD=''; C_RST=''
fi
# Parallel stderr-gated set: lines printed to >&2 use E_* (not C_*) so color tracks fd 2's TTY - no
# escape leak into a redirected stderr, and color still shows when only stderr is a terminal.
if [ -t 2 ] && [ -z "${NO_COLOR:-}" ]; then
E_GRN=$'\033[32m'; E_RED=$'\033[31m'; E_YEL=$'\033[33m'; E_DIM=$'\033[2m'; E_RST=$'\033[0m'
else
E_GRN=''; E_RED=''; E_YEL=''; E_DIM=''; E_RST=''
fi
# version + help
SLUICE_VERSION="0.9.0" # fallback when not a git checkout
sluice_version() { # git tag if $ROOT is our own checkout, else the baked constant
local v
if [ -e "$ROOT/.git" ] && v="$(git -C "$ROOT" describe --tags --always --dirty 2>/dev/null)" && [ -n "$v" ]; then
printf '%s' "${v#v}"
else
printf '%s' "$SLUICE_VERSION"
fi
}
# Passive "you're behind" notice for `sluice version`. Best-effort 2s GitHub check; silent on any
# failure/offline/opt-out (SLUICE_NO_UPDATE_CHECK=1), never aborts. Field-numeric compare (BSD sort
# has no -V); a dev build (X.Y.Z-N-g...) compares by its X.Y.Z base, so it won't nag.
check_update_notice() {
[ -z "${SLUICE_NO_UPDATE_CHECK:-}" ] || return 0
command -v curl >/dev/null 2>&1 || return 0
local cur_base latest newest
cur_base="$(sluice_version | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' || true)"
[ -n "$cur_base" ] || return 0
latest="$(curl -fsS --max-time 2 https://api.github.com/repos/Pyronewbic/Sluice/releases/latest 2>/dev/null \
| grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
| sed -E 's/.*"([^"]*)"$/\1/' 2>/dev/null || true)"
latest="${latest#v}"
[ -n "$latest" ] || return 0
newest="$(printf '%s\n%s\n' "$cur_base" "$latest" | sort -t. -k1,1n -k2,2n -k3,3n | tail -1)"
[ "$newest" = "$latest" ] && [ "$latest" != "$cur_base" ] \
&& printf ' update v%s available (you have v%s) - update sluice: brew upgrade sluice (see README)\n' "$latest" "$cur_base"
return 0
}
usage() {
cat <<EOF
sluice $(sluice_version) - run any project in a sandboxed, egress-firewalled container.
Usage: sluice [-b <name>] [command]
-b, --box <name> target any box by name from anywhere (before the command; see 'sluice ls')
Common:
(no command) build (if needed) + run SLUICE_RUN_CMD; scaffold a config if there's none
agent [name] [args] run a coding agent; trailing args run it one-shot; no name lists them
init [--force|--update] scaffold a sluice.config.sh by detecting the project's stack
(--update re-detects but keeps your allowlist/env/hardening; confirms)
learn review the hosts the proxy blocked and allowlist the ones you pick, live
(per-host, last run by default; offers .domain wildcards; no rebuild)
--all everything since boot; --print emits the list; --apply allows all;
--audit opens egress for one trusted run to discover every reached host
shell a bash shell in the sandbox (as the non-root sluice user)
run <cmd...> an ad-hoc command instead of SLUICE_RUN_CMD
Protected workspace (SLUICE_WORKSPACE=overlay - host repo mounted read-only, box works on a copy):
diff show what the box changed vs your repo (unified diff, .git excluded)
apply write the box's changes back onto your repo (confirms first)
Build & lifecycle:
build build the image (if missing or the config changed)
rebuild build + recreate the container - apply config/allowlist edits
update rebuild from scratch (re-resolve packages to latest) + refresh sluice.lock
stop remove the project's container
rm remove the project's container AND image
prune remove every sluice container + image (or only orphans: --orphans); confirms
Inspect:
doctor health check: engine, image, allowlist, blocked egress (--json)
ls list all boxes + posture (status, stack, allow/ports/lock, path); --running/--orphans/--stack <name>/--egress/--json
egress show what this box reached vs. was blocked (--json | --export | --verify)
--verify/--export accept --all to span every box's audit log (fleet-wide, no engine)
logs follow firewall + readiness logs
lock record installed apk+npm+pip+gem+go+cargo versions to sluice.lock (supply-chain audit)
--check fails on drift (CI gate, --json); --enforce is the strict variant; --diff shows it;
--sbom emits CycloneDX (--format spdx for SPDX); --scan vuln-checks via a host Grype/Trivy (--fail-on <sev>);
--pin writes sluice.pin (base digest + exact versions) for a SLUICE_PIN=1 replay build
smoke build (if needed) + run the image smoke test
Meta:
version show version + host runtime (engine, OS)
help show this help
Env: SLUICE_ENGINE SLUICE_RUNTIME=kata SLUICE_NO_BANNER SLUICE_YES SLUICE_NO_UPDATE_CHECK NO_COLOR
SLUICE_PIDS_LIMIT (default 4096) SLUICE_MEMORY (e.g. 4g; unset = no cap)
SLUICE_SECCOMP=hardened|browser|audit (extra syscall filter; hardened >= engine default)
SLUICE_READONLY_ROOT=1 (immutable rootfs; tmpfs + anon-volume the writable paths)
SLUICE_WORKSPACE=overlay (host repo read-only; box edits a copy - see 'diff'/'apply')
SLUICE_EGRESS_HARD_CAP_BYTES=N (preventive in-box egress cap) SLUICE_PIN=1 (verified pinned replay)
Config knobs (sluice.config.sh): see sluice.config.example.sh + docs/configuration.md
Docs: https://github.com/Pyronewbic/Sluice
EOF
}
# Per-command help (sluice <cmd> --help). Synopses mirror usage(); keeps `sluice run --help` etc. useful.
help_for() {
case "$1" in
run) echo "sluice run <cmd...> - run an ad-hoc command in the sandbox (builds/starts if needed)." ;;
shell) echo "sluice shell - a bash shell in the sandbox (non-root sluice user)." ;;
agent) echo "sluice agent [name] [args] - scaffold + run a coding-agent preset; args after the name run it one-shot; no name lists them." ;;
init) echo "sluice init [--force|--update] - scaffold a sluice.config.sh by detecting the stack (--update re-detects, keeping your edits)." ;;
learn) echo "sluice learn [--all] [--print|--apply|--audit] - review blocked hosts, allowlist your picks." ;;
build) echo "sluice build - build the image if missing or the config changed." ;;
rebuild) echo "sluice rebuild - build + recreate the container (apply config/allowlist edits)." ;;
update) echo "sluice update - rebuild from scratch (re-resolve packages) + refresh sluice.lock." ;;
diff) echo "sluice diff - (SLUICE_WORKSPACE=overlay) show what the box changed vs your repo." ;;
apply) echo "sluice apply - (SLUICE_WORKSPACE=overlay) write the box's changes back onto your repo (confirms; SLUICE_YES=1 non-interactive, SLUICE_APPLY_NO_DELETE=1 keeps deleted host files)." ;;
stop) echo "sluice stop - remove the project's container." ;;
rm) echo "sluice rm - remove the project's container AND image." ;;
prune) echo "sluice prune [--orphans] - remove every sluice container + image (or only orphans); confirms." ;;
doctor) echo "sluice doctor [--json] - health check: engine, image, allowlist, blocked egress." ;;
ls) echo "sluice ls [--running|--orphans|--stack <name>|--egress|--json] - list boxes + posture (allow/ports/lock; --egress adds live blocked counts). Posture populates after rebuild." ;;
egress) echo "sluice egress [--json | --export [--all] | --verify [--all] [--json]] - reached vs. blocked; --export the append-only audit log (JSONL), --verify its hash chain; --all spans every box (fleet-wide, no engine needed)." ;;
logs) echo "sluice logs - follow firewall + readiness logs." ;;
lock) echo "sluice lock [--check [--json] | --diff [--json] | --enforce [--json] | --sbom [--format cyclonedx|spdx] | --scan [--json] [--fail-on <sev>] | --pin] - record/verify/vuln-scan the supply-chain inventory; --pin writes a replay manifest." ;;
smoke) echo "sluice smoke - build (if needed) + run the image smoke test." ;;
version) echo "sluice version [--json] - show version + host runtime." ;;
*) usage ;;
esac
}
cmd_version() {
[ "${1:-}" = --json ] && { cmd_version_json; return 0; }
printf 'sluice %s\n' "$(sluice_version)"
local eng=""
if [ -n "${SLUICE_ENGINE:-}" ]; then eng="$SLUICE_ENGINE"
elif command -v docker >/dev/null 2>&1; then eng=docker
elif command -v podman >/dev/null 2>&1; then eng=podman; fi
if [ -n "$eng" ] && command -v "$eng" >/dev/null 2>&1; then
printf ' engine %s\n' "$("$eng" --version 2>/dev/null | head -1)"
else
printf ' engine %snone%s (install docker or podman)\n' "$C_RED" "$C_RST"
fi
printf ' os %s %s\n' "$(uname -s)" "$(uname -m)"
printf ' install %s\n' "$ROOT"
check_update_notice
}
# Machine-readable version/runtime for scripts + the control plane.
cmd_version_json() {
local eng=""
if [ -n "${SLUICE_ENGINE:-}" ]; then eng="$SLUICE_ENGINE"
elif command -v docker >/dev/null 2>&1; then eng=docker
elif command -v podman >/dev/null 2>&1; then eng=podman; fi
[ -n "$eng" ] && command -v "$eng" >/dev/null 2>&1 && eng="$("$eng" --version 2>/dev/null | head -1)"
printf '{"schema":"sluice.version/v1","version":"%s","engine":"%s","os":"%s","install":"%s"}\n' \
"$(_json_esc "$(sluice_version)")" "$(_json_esc "$eng")" "$(_json_esc "$(uname -s) $(uname -m)")" "$(_json_esc "$ROOT")"
}
# naming + diagnostics helpers (shared by run, learn, doctor)
derive_names() {
slug="$(printf '%s' "${SLUICE_NAME:-$(basename "$PROJECT_DIR")}" | tr '[:upper:]' '[:lower:]' | tr -C 'a-z0-9' '-')"
tag="sluice-$slug"; container="$tag"
}
# Always-on egress hosts (registries + GitHub); core/entrypoint.sh keeps its own in-container copy.
base_domains() { printf '%s' "github.com api.github.com codeload.github.com objects.githubusercontent.com registry.npmjs.org registry.yarnpkg.com"; }
# Public suffixes where the registrable domain sits one label below the last two - common second-level
# ccTLDs + dev-platform hosts that show up in allowlists. Not the full PSL (that'd be vendored data);
# the _collapsible guard makes an unlisted multi-part suffix fail "don't offer the wildcard", not over-allow.
# ccTLD second-levels + flat app/dev platforms + cloud storage at its true (deeper) suffix, so a
# multi-tenant host like a.s3.amazonaws.com collapses to a.s3... not the tenant-shared apex.
_PUBLIC_SUFFIXES="co.uk org.uk gov.uk ac.uk me.uk net.uk com.au net.au org.au gov.au edu.au co.nz net.nz org.nz co.jp ne.jp or.jp co.kr co.in co.za com.br com.cn com.mx com.sg com.tr github.io gitlab.io pages.dev workers.dev r2.dev vercel.app netlify.app web.app firebaseapp.com herokuapp.com azurewebsites.net cloudfront.net s3.amazonaws.com blob.core.windows.net storage.googleapis.com"
# Registrable parent (eTLD+1) of a host, public-suffix aware: the label just below the longest matching
# suffix. Returns the host unchanged when it IS a suffix / has nothing below one, so `learn` never
# offers a `.wildcard` equal to a public suffix. Wildcards stay offered, never forced.
parent_of() {
local host="$1" s head
for s in $_PUBLIC_SUFFIXES; do
case "$host" in
"$s") printf '%s\n' "$host"; return 0 ;;
*.$s) head="${host%.$s}"; printf '%s.%s\n' "${head##*.}" "$s"; return 0 ;;
esac
done
printf '%s\n' "$host" | awk -F. 'NF>=2{print $(NF-1)"."$NF; next}{print}'
}
# True when collapsing to ".$1" is safe to offer: at least two labels AND not itself a public suffix.
_collapsible() {
local p="$1" s
case "$p" in *.*) ;; *) return 1 ;; esac
for s in $_PUBLIC_SUFFIXES; do [ "$p" = "$s" ] && return 1; done
return 0
}
# Hash of config + core (+ base image ref), baked as an image label; rebuild when it changes.
# SLUICE_ALLOW_DOMAINS is excluded - applied at runtime (SLUICE_RUNTIME_ALLOW), so an allowlist edit
# (e.g. `sluice learn`) needs no rebuild.
config_hash() {
{ printf 'base=%s\n' "${SLUICE_BASE_IMAGE:-}"; grep -vE '^[[:space:]]*SLUICE_ALLOW_DOMAINS=' "$PROJECT_CONFIG"; \
find "$CORE" -type f | LC_ALL=C sort | while read -r f; do cat "$f"; done; \
for f in ${SLUICE_PREFETCH_FILES:-}; do [ -f "$PROJECT_DIR/$f" ] && cat "$PROJECT_DIR/$f"; done; \
printf 'pin=%s\n' "${SLUICE_PIN:-}"; \
if [ "${SLUICE_PIN:-}" = 1 ] && [ -f "$PROJECT_DIR/sluice.pin" ]; then cat "$PROJECT_DIR/sluice.pin"; fi; } \
| shasum | awk '{print $1}' | cut -c1-12
}
# ps-filter (not `inspect .State.Running`) so it works on both docker and nerdctl - nerdctl's native
# inspect has no docker-style .State key. grep -qx pins the exact name (not the -audit sibling).
running() { "$RUNNER" ps --filter "name=$container" --filter status=running --format '{{.Names}}' 2>/dev/null | grep -qx "$container"; }
# True when the HOST enforces SELinux (Fedora/RHEL/CentOS default). On such a host a bind mount is
# inaccessible to the box without a label, so sluice runs it label=disable (see the run paths).
selinux_enforcing() { [ -r /sys/fs/selinux/enforce ] && [ "$(cat /sys/fs/selinux/enforce 2>/dev/null)" = 1 ]; }
# Root-context maintenance execs (receipt/learn/apply) run as the container's root - NOT --user sluice.
# The image PATH must never let a uid-1000-writable dir (/home/sluice/.npm-global/bin) shadow a system
# tool here, or a planted ~/.npm-global/bin/tail runs as root. Force a clean system PATH on every such
# exec. Session execs (_exec_args) stay --user sluice and keep the full PATH for the workload's tools.
_ROOT_PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_root_exec() { "$RUNNER" exec -e "PATH=$_ROOT_PATH" "$@"; }
# True when the box's in-container audit log is actually READABLE. A uid-1000 workload can exhaust the
# pids cgroup so `<engine> exec` can't fork - _squid_log would then return empty (a FAILED read), which
# would misread as zero egress. Callers gate on this to record `unavailable` / fail the byte gate closed.
_audit_readable() { _root_exec "$container" true >/dev/null 2>&1; }
# squid access log. From _RCPT_OFFSET bytes when set (the run-scoped receipt); otherwise the last
# _SQUID_LOG_CAP bytes - a ceiling so an attacker can't inflate host CPU/IO by spamming the log and
# forcing an unbounded `cat | awk` on the box-level audit paths (egress/doctor/learn --all). 16 MiB
# holds far more than any real session; a truncated first line just gets skipped by the awk parsers.
_SQUID_LOG_CAP=16777216
_squid_log() {
if [ -n "${_RCPT_OFFSET:-}" ]; then
_root_exec "${1:-$container}" sh -c "tail -c +$(( _RCPT_OFFSET + 1 )) /var/log/squid/access.log" 2>/dev/null
else
_root_exec "${1:-$container}" sh -c "tail -c $_SQUID_LOG_CAP /var/log/squid/access.log" 2>/dev/null
fi
}
# Hostnames the proxy BLOCKED (SNI for HTTPS, Host for HTTP), from the running container's log.
blocked_hosts() {
_squid_log | awk '
{ sni="";
for (i=1;i<=NF;i++) if ($i ~ /^ssl_sni=/) sni=substr($i,9);
status=$3; url=$5;
if (status !~ /NONE_NONE/ && status !~ /TCP_DENIED/ && status !~ /\/000/) next;
host="";
if (sni != "" && sni != "-") host=sni;
else if (url ~ /^http:\/\//) { h=url; sub(/^http:\/\//,"",h); sub(/\/.*/,"",h); sub(/:.*/,"",h); host=h }
if (host == "" || host ~ /^[0-9.]+$/) next;
if (host !~ /^\.?[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/) next; # drop non-hostname chars: a raw SNI/Host carries $(),",ESC -> config-write RCE + terminal-escape injection
print host
}' | sort -u
}
# Hostnames the proxy ALLOWED (reached). reached_hosts_raw = one line per request (for counts);
# reached_hosts = unique. Optional $1 = container (learn --audit opens egress, so every host logs as a success).
reached_hosts_raw() {
_squid_log "$@" | awk '
{ sni="";
for (i=1;i<=NF;i++) if ($i ~ /^ssl_sni=/) sni=substr($i,9);
status=$3; url=$5;
if (status ~ /NONE_NONE/ || status ~ /TCP_DENIED/ || status ~ /\/000/) next;
host="";
if (sni != "" && sni != "-") host=sni;
else if (url ~ /^http:\/\//) { h=url; sub(/^http:\/\//,"",h); sub(/\/.*/,"",h); sub(/:.*/,"",h); host=h }
if (host == "" || host ~ /^[0-9.]+$/) next;
if (host !~ /^\.?[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/) next; # drop non-hostname chars: a raw SNI/Host carries $(),",ESC -> config-write RCE + terminal-escape injection
print host
}'
}
reached_hosts() { reached_hosts_raw "$@" | sort -u; }
# Per-host egress rows for the receipt + `sluice egress`: one awk pass over the (offset-aware) proxy
# log -> "<class>\t<host>\t<count>\t<bytes>". class=reached if the host ever got through, else blocked
# (reached-precedence wins on mixed lines); count = successes (reached) or denials (blocked); bytes =
# tx+rx. The example.* boot canary + already-allowlisted /000-race hosts are dropped (matches blocked_new).
egress_rows() {
_squid_log "$@" | awk -v allow=" $(allowed_domains) " '
# Is host h allowlisted? Exact match, or covered by a leading-dot wildcard (.x matches x + *.x),
# mirroring squid dstdomain - so learn never re-proposes a host a `.domain` entry already covers.
function allowed(h, n,i,t,tl,toks) {
if (index(allow, " " h " ")) return 1;
n = split(allow, toks, " ");
for (i=1;i<=n;i++) { t=toks[i]; if (substr(t,1,1)==".") { tl=length(t);
if (h==substr(t,2) || (length(h)>tl && substr(h,length(h)-tl+1)==t)) return 1; } }
return 0;
}
{ sni=""; tx=0; rx=0;
for (i=1;i<=NF;i++) {
if ($i ~ /^ssl_sni=/) sni=substr($i,9);
else if ($i ~ /^tx=/) tx=substr($i,4)+0;
else if ($i ~ /^rx=/) rx=substr($i,4)+0;
}
status=$3; url=$5; host="";
if (sni != "" && sni != "-") host=sni;
else if (url ~ /^http:\/\//) { h=url; sub(/^http:\/\//,"",h); sub(/\/.*/,"",h); sub(/:.*/,"",h); host=h }
if (host == "" || host ~ /^[0-9.]+$/) next;
if (host !~ /^\.?[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/) next; # drop non-hostname chars: a raw SNI/Host carries $(),",ESC -> config-write RCE + terminal-escape injection
bytes[host] += tx + rx; seen[host]=1;
if (status ~ /NONE_NONE/ || status ~ /TCP_DENIED/ || status ~ /\/000/) deny[host]++; else succ[host]++;
}
END {
for (h in seen) {
if (h ~ /^example\.(com|net|org)$/) continue;
if (succ[h] > 0) printf "reached\t%s\t%d\t%d\n", h, succ[h], bytes[h];
else if (!allowed(h)) printf "blocked\t%s\t%d\t%d\n", h, deny[h], bytes[h];
}
}'
}
# bytes -> human (B / KB / MB, one decimal for KB+).
_human_bytes() {
awk -v b="${1:-0}" 'BEGIN{ if (b<1024) printf "%d B", b; else if (b<1048576) printf "%.1f KB", b/1024; else printf "%.1f MB", b/1048576 }'
}
# Total bytes the box SENT OUT to hosts it actually reached (tx=%>st, the upload/request side) - the
# exfil-relevant volume for the SLUICE_EGRESS_MAX_BYTES budget. Blocked requests never left the proxy,
# so they don't count. Offset-aware via _squid_log (scoped to the run for the receipt).
egress_tx_total() {
_squid_log | awk '
{ tx=0; status=$3;
if (status ~ /NONE_NONE/ || status ~ /TCP_DENIED/ || status ~ /\/000/) next; # blocked: did not leave
for (i=1;i<=NF;i++) if ($i ~ /^tx=/) tx=substr($i,4)+0;
total += tx;
} END { print total+0 }'
}
# Bytes SENT OUT keyed by reached host: "<host>\t<tx>". Same exfil-direction measure as egress_tx_total
# (tx only; blocked requests never left the proxy), grouped by host for the SLUICE_EGRESS_HOST_BUDGETS
# per-host gate. Offset-aware via _squid_log (scoped to the run for the receipt). Mirrors egress_rows'
# host + hostname-charset parsing.
egress_tx_by_host() {
_squid_log | awk '
{ sni=""; tx=0; status=$3; url=$5;
if (status ~ /NONE_NONE/ || status ~ /TCP_DENIED/ || status ~ /\/000/) next; # blocked: did not leave
for (i=1;i<=NF;i++) { if ($i ~ /^ssl_sni=/) sni=substr($i,9); else if ($i ~ /^tx=/) tx=substr($i,4)+0; }
host="";
if (sni != "" && sni != "-") host=sni;
else if (url ~ /^http:\/\//) { h=url; sub(/^http:\/\//,"",h); sub(/\/.*/,"",h); sub(/:.*/,"",h); host=h }
if (host == "" || host ~ /^[0-9.]+$/) next;
if (host !~ /^\.?[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/) next; # drop non-hostname chars (SNI/Host can carry $(),",ESC)
tot[host] += tx;
}
END { for (h in tot) printf "%s\t%d\n", h, tot[h] }'
}
# Resolve a host to its SLUICE_EGRESS_HOST_BUDGETS cap in bytes (empty = no budget for this host).
# Tokens are "host=bytes" (exact) or ".wildcard=bytes" (.x matches x + *.x, squid dstdomain style).
# Exact match wins outright; among wildcards the longest (most specific) wins. set -f so the unquoted
# split can't glob a value.
_host_budget_for() {
local host="$1" tok thost tbytes bare best="" bestlen=-1
set -f
for tok in ${SLUICE_EGRESS_HOST_BUDGETS:-}; do
case "$tok" in *=*) ;; *) continue ;; esac
thost="${tok%%=*}"; tbytes="${tok#*=}"
case "$tbytes" in ''|*[!0-9]*) continue ;; esac
if [ "$thost" = "$host" ]; then best="$tbytes"; break; fi # exact beats any wildcard
case "$thost" in
.*) bare="${thost#.}"
if [ "$host" = "$bare" ] || case "$host" in *"$thost") true ;; *) false ;; esac; then
[ "${#thost}" -gt "$bestlen" ] && { best="$tbytes"; bestlen="${#thost}"; }
fi ;;
esac
done
set +f
printf '%s' "$best"
}
# Per-entry SLUICE_ALLOW_IPS accounting: read the OUTPUT chain's counters (the direct-egress jumps route
# through SLUICE-ALLOWIPS), emitting "<entry>\t<packets>\t<bytes>" per entry - the first visibility into
# the direct-IP escape hatch, which bypasses squid and was invisible to the receipt. Root-side iptables
# read (NET_ADMIN kept; /sbin on _ROOT_PATH). Empty read is gated by _audit_readable at the call site.
# Note: these count ATTEMPTED wire bytes (headers included; packets a budget DROP later ate are counted);
# entries on :80/:443 are dead (the NAT REDIRECT wins) and show 0.
allowips_rows() {
_root_exec "$container" iptables -nvxL OUTPUT 2>/dev/null | awk '
$3=="SLUICE-ALLOWIPS" {
dst=$9; port="";
for (i=10;i<=NF;i++) if ($i ~ /^dpt:/) port=substr($i,5);
entry=(port=="") ? dst : dst":"port;
printf "%s\t%d\t%d\n", entry, $1, $2;
}'
}
# Firewall-dropped total: parse the OUTPUT chain's policy-DROP counter ("Chain OUTPUT (policy DROP N
# packets, M bytes)") - the first visibility into non-HTTP blocked egress attempts. Emits "<packets>\t<bytes>".
fw_dropped() {
_root_exec "$container" iptables -nvxL OUTPUT 2>/dev/null | awk '
/^Chain OUTPUT / {
p=0; b=0;
for (i=1;i<=NF;i++) { if ($i ~ /^packets,?$/) p=$(i-1); if ($i ~ /^bytes\)?$/) b=$(i-1); }
printf "%d\t%d\n", p, b; exit;
}'
}
# DNS query audit (SLUICE_DNS_AUDIT=1): read dnsmasq's query log and group by immediate parent domain,
# emitting "<parent>\t<queries>\t<unique_names>". A DNS tunnel concentrates MANY unique leftmost labels
# under one parent (exfil as DNS labels), so a high unique-name count per parent is the signal. Queried
# names are attacker-controlled bytes -> the same hostname charset gate as SNI (drops $(),",ESC). Reuses
# the _SQUID_LOG_CAP DoS ceiling. Empty read gated by _audit_readable at the call site.
dns_rows() {
_root_exec "$container" sh -c "tail -c $_SQUID_LOG_CAP /var/log/squid/dns.log" 2>/dev/null | awk '
/query\[/ {
name="";
for (i=1;i<=NF;i++) if ($i ~ /^query\[/) { name=$(i+1); break }
if (name=="" || name !~ /^[A-Za-z0-9._-]+$/) next; # non-hostname bytes: skip (RCE/escape guard)
n=split(name, L, ".");
if (n<=2) parent=name; else { parent=L[2]; for (j=3;j<=n;j++) parent=parent"."L[j] }
cnt[parent]++;
key=parent SUBSEP name;
if (!(key in seen)) { seen[key]=1; uniq[parent]++ }
}
END { for (p in cnt) printf "%s\t%d\t%d\n", p, cnt[p], uniq[p] }'
}
# Packets the SLUICE_ALLOW_IPS shared budget DROP'd (SLUICE_ALLOW_IPS_MAX_BYTES exhausted mid-run). A
# non-zero count means the box hit the direct-IP cap during the run -> `sluice egress` fails the gate.
# Empty when the chain/budget isn't present (no SLUICE-ALLOWIPS DROP rule).
allowips_dropped() {
_root_exec "$container" iptables -nvxL SLUICE-ALLOWIPS 2>/dev/null | awk '
$3=="DROP" { print $1+0; found=1; exit } END { if (!found) print "" }'
}
# ",\"allow_ips\":[...],\"fw_dropped\":{...}" for the receipt + `sluice egress --json`, or "" when
# SLUICE_ALLOW_IPS is unset. One iptables read each for the per-entry counters and the policy-DROP total.
_allowips_json_fields() {
[ -n "${SLUICE_ALLOW_IPS:-}" ] || { printf ''; return 0; }
local TAB e p b aij="" first=1 fwd fp fb; TAB="$(printf '\t')"
while IFS="$TAB" read -r e p b; do
[ -n "$e" ] || continue
[ "$first" = 1 ] && first=0 || aij="$aij,"
aij="$aij{\"entry\":\"$(_json_esc "$e")\",\"packets\":${p:-0},\"bytes\":${b:-0}}"
done <<EOF
$(allowips_rows 2>/dev/null || true)
EOF
fwd="$(fw_dropped 2>/dev/null || true)"; fp="${fwd%%"$TAB"*}"; fb="${fwd#*"$TAB"}"
case "$fp" in ''|*[!0-9]*) fp=0 ;; esac
case "$fb" in ''|*[!0-9]*) fb=0 ;; esac
printf ',"allow_ips":[%s],"fw_dropped":{"packets":%s,"bytes":%s}' "$aij" "$fp" "$fb"
}
# ",\"dns\":{...}" when SLUICE_DNS_AUDIT=1, else "". Sums dns_rows for totals + flags tunnel parents
# (unique names >= SLUICE_DNS_TUNNEL_THRESHOLD, default 500).
_dns_json_fields() {
[ "${SLUICE_DNS_AUDIT:-}" = 1 ] || { printf ''; return 0; }
local TAB p q u tq=0 tu=0 fl="" ffirst=1 thr; TAB="$(printf '\t')"
thr="${SLUICE_DNS_TUNNEL_THRESHOLD:-500}"; case "$thr" in ''|*[!0-9]*) thr=500 ;; esac
while IFS="$TAB" read -r p q u; do
[ -n "$p" ] || continue
tq=$((tq + q)); tu=$((tu + u))
if [ "$u" -ge "$thr" ]; then
[ "$ffirst" = 1 ] && ffirst=0 || fl="$fl,"
fl="$fl{\"parent\":\"$(_json_esc "$p")\",\"unique\":$u}"
fi
done <<EOF
$(dns_rows 2>/dev/null || true)
EOF
printf ',"dns":{"queries":%s,"unique":%s,"flagged":[%s]}' "$tq" "$tu" "$fl"
}
# The proxy-log byte offset captured at the start of the last `sluice` run (written to /run by the run
# arms). Lets `sluice learn` scope to that run instead of the whole boot; empty if no run / box rebooted.
# `|| true` on the cat: a missing offset file -> empty offset (callers' full-log fallback), never a
# pipefail that would abort the bare-assignment call sites (learn/doctor/ls) under set -e.
last_run_offset() { { _root_exec "$container" cat /run/sluice-run-offset 2>/dev/null || true; } | tr -dc 0-9; }
mark_run_start() { _root_exec "$container" sh -c 'wc -c < /var/log/squid/access.log | tr -dc 0-9 > /run/sluice-run-offset' 2>/dev/null || true; }
# sha256 of stdin (hex only). shasum ships on macOS + the Linux runners (config_hash already uses it).
_sha256() { shasum -a 256 2>/dev/null | awk '{print $1}'; }
# Arm the at-exit egress receipt for a session: snapshot the proxy-log position so the receipt is
# scoped to THIS run (not the box's whole boot), mark the run start so a later `learn` can scope to it,
# and trap the receipt on EXIT (fires on normal exit, die, or Ctrl-C). Shared by run-default/shell/run.
arm_receipt() {
_RCPT_OFFSET="$(_root_exec "$container" sh -c 'wc -c < /var/log/squid/access.log' 2>/dev/null | tr -dc 0-9)"
mark_run_start
trap show_egress_receipt EXIT
}
# This project's effective egress allowlist: config domains + the always-on base.
allowed_domains() { printf '%s %s' "${SLUICE_ALLOW_DOMAINS:-}" "$(base_domains)"; }
# True if a host is a known shared/public endpoint an attacker could also WRITE to - so data can be
# laundered out through it even though it's allowlisted (THREAT_MODEL "allowed-host laundering"; we
# splice, never decrypt). Heuristic + non-exhaustive; doctor nudges, never blocks.
laundering_host() {
set -- "${1#.}" # a leading-dot wildcard (.host, what `sluice learn` writes) covers the bare host
case "$1" in
*s3.amazonaws.com|*.s3.*.amazonaws.com|storage.googleapis.com|*.blob.core.windows.net|*.r2.cloudflarestorage.com|*.digitaloceanspaces.com) return 0 ;;
gist.github.com|gist.githubusercontent.com|raw.githubusercontent.com|*pastebin.com|paste.*|transfer.sh|0x0.st|file.io|*.tmpfiles.org) return 0 ;;
webhook.site|*.ngrok.io|*.ngrok-free.app|hooks.slack.com|*.requestbin.com|*.pipedream.net) return 0 ;;
api.openai.com|api.anthropic.com|generativelanguage.googleapis.com|api.cohere.ai) return 0 ;;
esac
return 1
}
# True if $host is on the baked DoH/DoT denylist (core/doh-endpoints.txt, the single source squid
# also reads). dstdomain semantics: a leading-dot entry matches the domain + subdomains; else exact.
doh_listed() {
# Match case-insensitively (squid dstdomain / dnsmasq are; the SNI regex accepts uppercase). $1 may be
# a leading-dot WILDCARD. Reject when the candidate IS a DoH endpoint, sits UNDER a DoH wildcard, OR is
# a wildcard that COVERS a DoH endpoint host - e.g. `.adguard.com` would re-allow the listed
# dns.adguard.com. The denylist is lowercase.
local cand ch entry eh
cand="$(printf '%s' "$1" | tr 'A-Z' 'a-z')"; ch="${cand#.}"
[ -f "$CORE/doh-endpoints.txt" ] || return 1
while IFS= read -r entry; do
case "$entry" in ''|\#*) continue ;; esac
eh="${entry#.}"
case "$entry" in
.*) case ".$ch" in *"$entry") return 0 ;; esac ;; # candidate is, or sits under, a DoH wildcard
*) [ "$ch" = "$entry" ] && return 0 ;; # candidate is the exact DoH host
esac
case "$cand" in .*) case ".$eh" in *"$cand") return 0 ;; esac ;; esac # wildcard candidate covers it
done < "$CORE/doh-endpoints.txt"
return 1
}
# Blocked hosts NOT already allowed - the genuinely-missing ones. Drops the transient
# startup-race /000 on allowlisted hosts (e.g. registry.npmjs.org), so it never proposes them.
blocked_new() {
local cur h
cur=" $(allowed_domains) "
blocked_hosts 2>/dev/null | while IFS= read -r h; do
[ -n "$h" ] || continue
case "$h" in example.com|example.net|example.org) continue;; esac # boot deny-canary, not an app host
case "$cur" in *" $h "*) ;; *) printf '%s\n' "$h";; esac
done
}
# Count of genuinely-denied hosts for a running box, WITHOUT sourcing its config (for `ls --egress`).
# Reuses blocked_new wholesale: a subshell overrides the two globals it reads - $container (which
# box's log to exec) and $SLUICE_ALLOW_DOMAINS (the box's live allowlist, read from the container).
# base_domains() is still added by allowed_domains(), so base hosts never count as blocked.
# Fail-closed: a zero is only trusted after _audit_readable confirms the exec path still works; an
# unreadable box (e.g. pids exhausted) emits EMPTY (unknown) - ls renders ? / null, never a false 0.
box_blocked_count() {
local al n
al="$(_root_exec "$1" cat /etc/squid/allowlist.txt 2>/dev/null | tr '\n' ' ' || true)"
n="$( ( container="$1"; SLUICE_ALLOW_DOMAINS="$al"; blocked_new 2>/dev/null | grep -c . ) || true )"
[ "${n:-0}" -gt 0 ] || ( container="$1"; _audit_readable ) || return 0 # a 0 may be a FAILED read - confirm it
printf '%s\n' "$n"
}
# `sluice egress [--json]`: the box's egress audit record (reached vs. blocked)
# reached_hosts = what the box actually reached; blocked_new = genuinely-denied hosts (not in the
# allowlist, and minus the transient startup-race noise doctor also filters). A control-plane feed.
cmd_egress() {
local mode="${1:-}" rows TAB
running || die "no running sandbox. Start it ('sluice'), exercise it, then run 'sluice egress'."
rows="$(egress_rows 2>/dev/null || true)"
# Empty rows mean "no egress" OR a FAILED in-box read (uid 1000 filled the pids cgroup so the audit
# exec couldn't fork). Never let a failed read pass as a clean zero - fail closed so a CI byte gate
# can't go green on an un-audited run.
if [ -z "$rows" ] && ! _audit_readable; then
if [ "$mode" = --json ]; then
printf '{"schema":"sluice.egress/v1","box":"%s","unavailable":true}\n' "$(_json_esc "$container")"
else
echo "[sluice] ${E_YEL:-}egress audit unavailable${E_RST:-} - could not read the in-box log (pids limit?); failing closed." >&2
fi
return 2
fi
TAB="$(printf '\t')"
# SLUICE_EGRESS_MAX_BYTES: a volume budget on what LEFT the box (tx to reached hosts). Over the cap,
# this command exits non-zero so CI can gate it - bounds how much can be laundered through an
# allowed host. Unset -> no gate (always exit 0, unchanged).
local cap="${SLUICE_EGRESS_MAX_BYTES:-}" tx over=0
case "$cap" in *[!0-9]*) cap="";; esac # non-numeric (or empty) -> no budget
tx="$(egress_tx_total 2>/dev/null || echo 0)"; case "$tx" in ''|*[!0-9]*) tx=0;; esac
[ -n "$cap" ] && [ "$tx" -gt "$cap" ] && over=1
# SLUICE_EGRESS_HOST_BUDGETS: a PER-HOST tx budget (bounds laundering through one allowed host more
# tightly than the whole-box cap). Any single reached host over its cap makes this command exit
# non-zero too - the same CI gate. Detective, boot-scoped (like the total cap). hb_tx_table is the
# per-host tx tally, computed once and reused by the human + JSON renders below.
local hb_over=0 hb_tx_table="" hb_host hb_tx hb_cap
if [ -n "${SLUICE_EGRESS_HOST_BUDGETS:-}" ]; then
hb_tx_table="$(egress_tx_by_host 2>/dev/null || true)"
while IFS="$TAB" read -r hb_host hb_tx; do
[ -n "$hb_host" ] || continue
hb_cap="$(_host_budget_for "$hb_host")"; [ -n "$hb_cap" ] || continue
case "$hb_tx" in ''|*[!0-9]*) hb_tx=0 ;; esac
[ "$hb_tx" -gt "$hb_cap" ] && hb_over=1
done <<EOF
$hb_tx_table
EOF
fi
[ "$hb_over" = 1 ] && over=1
# SLUICE_ALLOW_IPS_MAX_BYTES: the shared direct-IP budget DROPs packets once exhausted; a non-zero
# DROP counter means the run hit the cap -> fail the gate (bounds direct-IP exfil by volume).
if [ -n "${SLUICE_ALLOW_IPS:-}" ] && [ -n "${SLUICE_ALLOW_IPS_MAX_BYTES:-}" ]; then
local _aid; _aid="$(allowips_dropped 2>/dev/null || true)"; case "$_aid" in ''|*[!0-9]*) _aid=0 ;; esac
[ "$_aid" -gt 0 ] && over=1
fi
if [ "$mode" = --json ]; then
# Back-compat host arrays + a detailed hosts array (class/requests/bytes) for the control plane.
local allowed blocked hosts_json="" first=1 cls host cnt byt over_json=false
[ "$over" = 1 ] && over_json=true
allowed="$(printf '%s\n' "$rows" | awk -F"$TAB" '$1=="reached"{print $2}')"
blocked="$(printf '%s\n' "$rows" | awk -F"$TAB" '$1=="blocked"{print $2}')"
local _bud _ovb _htx
while IFS="$TAB" read -r cls host cnt byt; do
[ -n "$host" ] || continue
[ "$first" = 1 ] && first=0 || hosts_json="$hosts_json,"
_bud=null; _ovb=false
if [ -n "$hb_tx_table" ] && [ "$cls" = reached ]; then
_bud="$(_host_budget_for "$host")"
if [ -n "$_bud" ]; then
_htx="$(printf '%s\n' "$hb_tx_table" | awk -F"$TAB" -v h="$host" '$1==h{print $2; exit}')"
case "$_htx" in ''|*[!0-9]*) _htx=0 ;; esac
[ "$_htx" -gt "$_bud" ] && _ovb=true
else _bud=null; fi
fi
hosts_json="$hosts_json{\"host\":\"$(_json_esc "$host")\",\"class\":\"$cls\",\"requests\":$cnt,\"bytes\":$byt,\"budget\":$_bud,\"over_budget\":$_ovb}"
done <<EOF
$rows
EOF
# window=boot: unlike the at-exit receipt (run-scoped), this command reads the whole-boot egress
# window - so does its SLUICE_EGRESS_MAX_BYTES gate. Surfaced for the control plane (see docs/operations.md).
local _aipf _dnsf; _aipf="$(_allowips_json_fields)"; _dnsf="$(_dns_json_fields)"
printf '{"schema":"sluice.egress/v1","box":"%s","window":"boot","allowed":%s,"blocked":%s,"tx_bytes":%s,"budget":%s,"over_budget":%s,"hosts":[%s]%s%s}\n' \
"$(_json_esc "$container")" \
"$(printf '%s\n' "$allowed" | _json_arr)" "$(printf '%s\n' "$blocked" | _json_arr)" \
"$tx" "${cap:-null}" "$over_json" "$hosts_json" "$_aipf" "$_dnsf"
return "$over"
fi
if [ -n "$rows" ]; then
# Summary header (reached/blocked tally + total bytes) then host | verdict | requests | bytes
# (reached): reached first (by bytes desc), then blocked. Counts/total computed in the same awk pass.
local nblocked; nblocked="$(printf '%s\n' "$rows" | awk -F"$TAB" '$1=="blocked"' | grep -c . || true)"
printf '%s\n' "$rows" | sort -t"$TAB" -k1,1r -k4,4nr -k2,2 \
| awk -F"$TAB" -v box="$container" -v grn="$C_GRN" -v red="$C_RED" -v dim="$C_DIM" -v bld="$C_BLD" -v rst="$C_RST" '
function human(b){ if(b<1024) return b" B"; else if(b<1048576) return sprintf("%.1f KB",b/1024); else return sprintf("%.1f MB",b/1048576) }
{ c[NR]=$1; h[NR]=$2; n[NR]=$3; b[NR]=$4; total+=$4; if($1=="reached") nr++; else nb++; if(length($2)>w) w=length($2) }
END { printf "%s%s egress%s %d reached, %d blocked, %s\n", bld, box, rst, nr, nb, human(total);
for(i=1;i<=NR;i++){
if(c[i]=="reached") printf " %-*s %s[reached]%s %3d req %s\n", w, h[i], grn, rst, n[i], human(b[i]);
else printf " %-*s %s[blocked]%s %3d req\n", w, h[i], red, rst, n[i];
} }'
# C1: blocked rows carry no next step here (unlike the receipt's per-row annotation) - one trailing nudge.
[ "${nblocked:-0}" -gt 0 ] && echo " ${C_DIM}${nblocked} host(s) blocked - allow with 'sluice learn'${C_RST}"
else
echo "${C_BLD}$container egress${C_RST}"
echo " ${C_DIM}(nothing yet - exercise the app, then re-run)${C_RST}"
fi
if [ -n "$cap" ]; then
if [ "$tx" -gt "$cap" ]; then echo " ${C_RED}egress budget EXCEEDED${C_RST}: $(_human_bytes "$tx") sent > $(_human_bytes "$cap") cap (SLUICE_EGRESS_MAX_BYTES)"
else echo " ${C_DIM}egress budget: $(_human_bytes "$tx") sent / $(_human_bytes "$cap") cap${C_RST}"; fi
fi
# Per-host budget breaches (SLUICE_EGRESS_HOST_BUDGETS): one line per host over its own cap.
if [ "$hb_over" = 1 ]; then
printf '%s\n' "$hb_tx_table" | while IFS="$TAB" read -r hb_host hb_tx; do
[ -n "$hb_host" ] || continue
hb_cap="$(_host_budget_for "$hb_host")"; [ -n "$hb_cap" ] || continue
case "$hb_tx" in ''|*[!0-9]*) hb_tx=0 ;; esac
[ "$hb_tx" -gt "$hb_cap" ] && echo " ${C_RED}host budget EXCEEDED${C_RST}: $hb_host sent $(_human_bytes "$hb_tx") > $(_human_bytes "$hb_cap") cap (SLUICE_EGRESS_HOST_BUDGETS)"
done
fi
# SLUICE_ALLOW_IPS direct-egress accounting (the escape hatch that bypasses squid - now metered).
if [ -n "${SLUICE_ALLOW_IPS:-}" ]; then
local _e _p _b _fwd _fp _fb
allowips_rows 2>/dev/null | while IFS="$TAB" read -r _e _p _b; do
[ -n "$_e" ] || continue
echo " ${C_DIM}direct-ip${C_RST} $_e $_p pkt $(_human_bytes "${_b:-0}")"
done
_fwd="$(fw_dropped 2>/dev/null || true)"; _fp="${_fwd%%"$TAB"*}"; _fb="${_fwd#*"$TAB"}"
case "$_fp" in ''|*[!0-9]*) _fp=0 ;; esac
[ "$_fp" -gt 0 ] && echo " ${C_DIM}firewall dropped $_fp non-HTTP/off-allowlist packet(s)${C_RST}"
if [ -n "${SLUICE_ALLOW_IPS_MAX_BYTES:-}" ]; then
local _aid2; _aid2="$(allowips_dropped 2>/dev/null || true)"; case "$_aid2" in ''|*[!0-9]*) _aid2=0 ;; esac
[ "$_aid2" -gt 0 ] && echo " ${C_RED}direct-ip budget EXCEEDED${C_RST}: $_aid2 pkt dropped (SLUICE_ALLOW_IPS_MAX_BYTES)"
fi
fi
# DNS query audit (SLUICE_DNS_AUDIT=1): volume + tunnel-pattern flags.
if [ "${SLUICE_DNS_AUDIT:-}" = 1 ]; then
local _dp _dq _du _dtq=0 _dtu=0 _thr; _thr="${SLUICE_DNS_TUNNEL_THRESHOLD:-500}"; case "$_thr" in ''|*[!0-9]*) _thr=500 ;; esac
while IFS="$TAB" read -r _dp _dq _du; do
[ -n "$_dp" ] || continue; _dtq=$((_dtq + _dq)); _dtu=$((_dtu + _du))
[ "$_du" -ge "$_thr" ] && echo " ${C_RED}possible DNS-tunnel pattern${C_RST} under $_dp ($_du unique names)"
done <<EOF
$(dns_rows 2>/dev/null || true)
EOF
echo " ${C_DIM}dns: $_dtq queries, $_dtu unique names${C_RST}"
fi
# C2: make the tamper-evident audit log discoverable (has-rows human path only; the empty case stays quiet).
[ -n "$rows" ] && echo " ${C_DIM}audit log: sluice egress --export | --verify${C_RST}"
return "$over"
}
# `sluice egress --export`: emit the append-only egress audit log (JSONL, one record per run with
# egress) for SIEM/CI ingestion. Reads the host-side store, so it works even when the box is down.
cmd_egress_export() {
local log="${XDG_STATE_HOME:-$HOME/.local/state}/sluice/$slug/egress-log.jsonl"
[ -f "$log" ] || { echo "[sluice] no egress log yet at $(_tilde "$log") - run the box first." >&2; return 0; }
cat "$log"
}
# Walk ONE egress-log.jsonl hash chain. Sets _VCF_RECORDS / _VCF_BROKEN / _VCF_REASON and returns 0 if
# intact, 1 on the first break (self-hash / prev-link) or an unreadable file. Byte-identical chain
# semantics to the old inline loop - the `|| [ -n "$line" ]` unterminated-tail catch and the blank-line
# continue are both pinned by test/verify-receipt-unit.bats (which must pass unmodified). Shared by the
# single-box `egress --verify` and the fleet `egress --verify --all`; M3's rotation adds a `rotation-link`
# reason on top of this walker.
_verify_chain_file() {
local log="$1"
_VCF_RECORDS=0; _VCF_BROKEN=""; _VCF_REASON=""
# A file we cannot read (perms) reports unreadable, never a silent pass (fail closed, like the audit reads).
[ -r "$log" ] || { _VCF_REASON=unreadable; return 1; }
local n=0 prev="0000000000000000000000000000000000000000000000000000000000000000" line payload self pfield
while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue # tolerate blank lines (don't hash "" into a bogus TAMPERED); count only real records
n=$((n+1))
self="$(printf '%s' "$line" | sed -n 's/.*,"self":"\([0-9a-f]*\)"}$/\1/p')"
payload="$(printf '%s' "$line" | sed 's/,"self":"[0-9a-f]*"}$/}/')"
pfield="$(printf '%s' "$line" | sed -n 's/.*,"prev":"\([0-9a-f]*\)".*/\1/p')"
if [ -z "$self" ] || [ "$(printf '%s' "$payload" | _sha256)" != "$self" ]; then
_VCF_RECORDS="$n"; _VCF_BROKEN="$n"; _VCF_REASON="self-hash"; return 1
fi
if [ "$pfield" != "$prev" ]; then
_VCF_RECORDS="$n"; _VCF_BROKEN="$n"; _VCF_REASON="prev-link"; return 1
fi
prev="$self"
done < "$log"
_VCF_RECORDS="$n"; return 0
}
# `sluice egress --verify`: walk the hash chain of the egress audit log; OK only if every line's
# self-hash recomputes and its prev links to the previous line's self (genesis = 64 zeros). Exits
# non-zero on the first break (tamper / reorder / truncation) - a CI integrity gate on the receipts.
cmd_egress_verify() {
# Parse the one optional flag strictly (mirrors cmd_scan/_drift_report): a typo'd gate flag must die,
# not silently downgrade to the human path at exit 0 (a CI 'egress --verify --jsonn' would lose its JSON).
local json=0
case "${1:-}" in
--json) json=1 ;;
"") ;;
*) die "usage: sluice egress --verify [--json]" ;;
esac
local log="${XDG_STATE_HOME:-$HOME/.local/state}/sluice/$slug/egress-log.jsonl"
# No log = an empty chain: trivially intact (0 records), exit 0 - unchanged.
if [ ! -f "$log" ]; then
if [ "$json" = 1 ]; then echo '{"schema":"sluice.egress-verify/v1","verified":true,"records":0,"broken_line":null,"reason":null}'
else echo "[sluice] no egress log yet at $(_tilde "$log")." >&2; fi
return 0
fi
if _verify_chain_file "$log"; then
if [ "$json" = 1 ]; then printf '{"schema":"sluice.egress-verify/v1","verified":true,"records":%d,"broken_line":null,"reason":null}\n' "$_VCF_RECORDS"
else echo "[sluice] ${C_GRN}egress log verified${C_RST}: $_VCF_RECORDS record(s), hash chain intact ($(_tilde "$log"))"; fi
return 0
fi
if [ "$json" = 1 ]; then
local _bl="$_VCF_BROKEN"; [ -n "$_bl" ] || _bl=null
printf '{"schema":"sluice.egress-verify/v1","verified":false,"records":%d,"broken_line":%s,"reason":"%s"}\n' "$_VCF_RECORDS" "$_bl" "$_VCF_REASON"
else
case "$_VCF_REASON" in
prev-link) echo "[sluice] ${E_RED}egress log TAMPERED${E_RST}: line $_VCF_BROKEN prev-link broken - reordered or dropped ($(_tilde "$log"))" >&2 ;;
unreadable) echo "[sluice] ${E_RED}egress log unreadable${E_RST}: $(_tilde "$log")" >&2 ;;
*) echo "[sluice] ${E_RED}egress log TAMPERED${E_RST}: line $_VCF_BROKEN self-hash mismatch ($(_tilde "$log"))" >&2 ;;
esac
fi
return 1
}
# `sluice egress --verify --all [--json]`: walk EVERY box's egress chain in one pass - the fleet-wide
# integrity gate. Pure host-side file reads (no engine, no per-box config), so it covers orphaned boxes
# and runs with the daemon down. Exit 1 if any chain is broken/unreadable, 0 on an intact or empty fleet.
cmd_egress_verify_all() {
local json=0
case "${1:-}" in --json) json=1 ;; "") ;; *) die "usage: sluice egress --verify --all [--json]" ;; esac
local store="${XDG_STATE_HOME:-$HOME/.local/state}/sluice" logs
# LC_ALL=C slug order; the */ glob skips the dot-prefixed .policy-cache dir + the .mask-empty stub.
logs="$(for _l in "$store"/*/egress-log.jsonl; do [ -f "$_l" ] && printf '%s\n' "$_l"; done | LC_ALL=C sort)"
if [ -z "$logs" ]; then
if [ "$json" = 1 ]; then echo '{"schema":"sluice.fleet-verify/v1","verified":true,"boxes_total":0,"boxes_broken":0,"boxes":[]}'
else echo "[sluice] no egress logs yet under $(_tilde "$store")." >&2; fi
return 0
fi
local total=0 broken=0 first=1 boxes_json="" log s bslug bok bl reason
while IFS= read -r log; do
[ -n "$log" ] || continue
s="${log%/egress-log.jsonl}"; bslug="${s##*/}"
total=$((total+1))
if _verify_chain_file "$log"; then bok=true; bl=null; reason=null
else bok=false; broken=$((broken+1)); bl="$_VCF_BROKEN"; [ -n "$bl" ] || bl=null; reason="\"$_VCF_REASON\""; fi
if [ "$json" = 1 ]; then
[ "$first" = 1 ] && first=0 || boxes_json="$boxes_json,"
boxes_json="$boxes_json{\"box\":\"sluice-$(_json_esc "$bslug")\",\"slug\":\"$(_json_esc "$bslug")\",\"state_dir\":\"$(_json_esc "$s")\",\"records\":$_VCF_RECORDS,\"verified\":$bok,\"broken_line\":$bl,\"reason\":$reason}"
elif [ "$bok" = true ]; then
printf ' %-24s %s record(s) %sintact%s\n' "sluice-$bslug" "$_VCF_RECORDS" "$C_GRN" "$C_RST"
elif [ "$_VCF_REASON" = unreadable ]; then
printf ' %-24s %sunreadable%s\n' "sluice-$bslug" "$C_RED" "$C_RST"
else
printf ' %-24s %sTAMPERED%s line %s (%s)\n' "sluice-$bslug" "$C_RED" "$C_RST" "$_VCF_BROKEN" "$_VCF_REASON"
fi
done <<EOF
$logs
EOF
if [ "$json" = 1 ]; then
local verified=true; [ "$broken" -gt 0 ] && verified=false
printf '{"schema":"sluice.fleet-verify/v1","verified":%s,"boxes_total":%d,"boxes_broken":%d,"boxes":[%s]}\n' "$verified" "$total" "$broken" "$boxes_json"
elif [ "$broken" -gt 0 ]; then
echo "[sluice] ${E_RED}$broken of $total box(es) TAMPERED / unreadable${E_RST}" >&2
else
echo "[sluice] ${C_GRN}all $total box(es) intact${C_RST}"
fi
if [ "$broken" -gt 0 ]; then return 1; fi
return 0
}
# `sluice egress --export --all`: concatenate every box's append-only JSONL log, slug-sorted, for a
# SIEM/CI to ingest the whole fleet at once. Each record carries its own `box`, so a consumer regroups
# by `.box` regardless of order. Host-side reads only (works with the daemon down / on orphans).
cmd_egress_export_all() {
local store="${XDG_STATE_HOME:-$HOME/.local/state}/sluice" logs log
logs="$(for _l in "$store"/*/egress-log.jsonl; do [ -f "$_l" ] && printf '%s\n' "$_l"; done | LC_ALL=C sort)"
[ -n "$logs" ] || { echo "[sluice] no egress logs yet under $(_tilde "$store")." >&2; return 0; }
while IFS= read -r log; do [ -n "$log" ] && cat "$log"; done <<EOF
$logs
EOF
}
# sluice.lock: a committable inventory of the built image. base ref + every apk
# (name/version/checksum) + global npm pkg, introspected from the image (awk/jq run in-image via a
# heredoc), sorted for stable diffs. Defined above cmd_doctor for the early `doctor` dispatch.
current_inventory() {
local baseref bdig
baseref="${SLUICE_BASE_IMAGE:-cgr.dev/chainguard/wolfi-base}"
bdig="$("$ENGINE" image inspect "$baseref" --format '{{ if .RepoDigests }}{{ index .RepoDigests 0 }}{{ end }}' 2>/dev/null || true)"
printf 'base %s\n' "${bdig:-$baseref}"
"$ENGINE" run --rm -i --entrypoint sh "$tag" 2>/dev/null <<'INTROSPECT' | LC_ALL=C sort -u
awk 'BEGIN{RS="";FS="\n"}{p=v=c="";for(i=1;i<=NF;i++){t=substr($i,1,2);if(t=="P:")p=substr($i,3);else if(t=="V:")v=substr($i,3);else if(t=="C:")c=substr($i,3)}if(p!="")printf "apk %s %s %s\n",p,v,c}' /lib/apk/db/installed
npm ls -g --depth=0 --json 2>/dev/null | jq -r '(.dependencies // {})|to_entries[]|"npm \(.key) \(.value.version)"' 2>/dev/null
command -v pip3 >/dev/null 2>&1 && su -s /bin/sh sluice -c 'HOME=/home/sluice pip3 list --format=json 2>/dev/null' 2>/dev/null | jq -r '.[]|"pip \(.key//.name|ascii_downcase) \(.value//.version)"' 2>/dev/null # as sluice: system + the project's --user site (root pip3 misses it)
command -v pipx >/dev/null 2>&1 && su -s /bin/sh sluice -c 'HOME=/home/sluice pipx list --json 2>/dev/null' 2>/dev/null | jq -r '(.venvs//{})|to_entries[]|.value.metadata.main_package|"pip \(.package|ascii_downcase) \(.package_version)"' 2>/dev/null # pipx apps live in isolated venvs
command -v gem >/dev/null 2>&1 && gem list --local --quiet 2>/dev/null | awk '{name=$1;rest=$0;sub(/^[^(]*\(/,"",rest);sub(/\).*$/,"",rest);n=split(rest,vs,/, */);for(i=1;i<=n;i++){v=vs[i];sub(/^default: */,"",v);if(v!="")printf "gem %s %s\n",name,v}}'
command -v go >/dev/null 2>&1 && { gb=""; for d in "$(go env GOBIN 2>/dev/null)" "$(go env GOPATH 2>/dev/null)/bin" /home/sluice/go/bin; do [ -n "$d" ] && [ -d "$d" ] || continue; case " $gb " in *" $d "*) ;; *) gb="$gb $d";; esac; done; for d in $gb; do for f in "$d"/*; do [ -f "$f" ] && [ -x "$f" ] || continue; go version -m "$f" 2>/dev/null | awk '$1=="mod"{print "go "$2" "$3; exit}'; done; done; }
command -v cargo >/dev/null 2>&1 && { for ch in "${CARGO_HOME:-$HOME/.cargo}" /home/sluice/.cargo /root/.cargo; do [ -f "$ch/.crates2.json" ] || continue; jq -r '.installs|keys[]|split(" ")|"cargo \(.[0]) \(.[1])"' "$ch/.crates2.json" 2>/dev/null; break; done; }
true
INTROSPECT
}
# Build (if needed) and write ./sluice.lock from the image inventory.
write_lock() {
maybe_build
local lock="$PROJECT_DIR/sluice.lock" inv na nn np ng ngo nc parts deltarows="" had_lock=0
inv="$(current_inventory)"
# Fail CLOSED: current_inventory's in-image read is masked by a `sort -u` pipe and consumed via a
# command substitution, so a failed engine read can't trip set -e - it returns base-ref only. A real
# Wolfi box always has apks, so a missing apk line means the read failed; refuse to write a hollow lock
# (a base-only artifact reported as success, then --check flags every real package as drift).
printf '%s\n' "$inv" | grep -q '^apk ' || die "could not read the image inventory - refusing to write a hollow sluice.lock"
# Capture the supply-chain delta vs the existing lock BEFORE overwriting (reuse $inv; no re-introspect).
[ -f "$lock" ] && { had_lock=1; deltarows="$(classify_drift "$(lock_drift "$inv")")"; }
{
printf "# sluice.lock - inventory of the built sandbox image (%s).\n" "$tag"
printf "# Audit/drift artifact, NOT a reproducibility guarantee (Wolfi apk is a rolling repo).\n"
printf "# Generated by 'sluice lock'; refresh with 'sluice update'.\n"
printf '%s\n' "$inv"
} > "$lock"
na="$(printf '%s\n' "$inv" | grep -c '^apk ' || true)" # grep -c exits 1 on zero matches; tolerate
nn="$(printf '%s\n' "$inv" | grep -c '^npm ' || true)" # (a box with no global npm packages)
np="$(printf '%s\n' "$inv" | grep -c '^pip ' || true)"
ng="$(printf '%s\n' "$inv" | grep -c '^gem ' || true)"
ngo="$(printf '%s\n' "$inv" | grep -c '^go ' || true)"
nc="$(printf '%s\n' "$inv" | grep -c '^cargo ' || true)"
parts="$na apk"
[ "$nn" -gt 0 ] && parts="$parts + $nn npm"
[ "$np" -gt 0 ] && parts="$parts + $np pip"
[ "$ng" -gt 0 ] && parts="$parts + $ng gem"
[ "$ngo" -gt 0 ] && parts="$parts + $ngo go"
[ "$nc" -gt 0 ] && parts="$parts + $nc cargo"
echo "[sluice] wrote $lock ($parts packages)"
if [ "$had_lock" = 1 ] && [ -n "$deltarows" ]; then
echo "[sluice] supply-chain delta since last lock: +$(printf '%s\n' "$deltarows" | grep -c '^add' || true) -$(printf '%s\n' "$deltarows" | grep -c '^del' || true) ~$(printf '%s\n' "$deltarows" | grep -c '^chg' || true)"
printf '%s\n' "$deltarows" | render_drift_human
elif [ "$had_lock" = 1 ]; then
# C4: an unchanged re-lock is silent otherwise - confirm it, mirroring --check's "lock in sync".
echo "[sluice] ${C_GRN}no supply-chain change since last lock${C_RST}"
fi
}
# Pin inventory: current_inventory's package set (apk/npm/pip/gem/go/cargo, with the frozen
# `apk name ver checksum` shape) but with the base line replaced by a DIGEST-checked one. The pin's
# whole point is a rebuildable coordinate, so it fails closed if the base can't be resolved to a
# @sha256 digest - pulling the base once if the local engine has no RepoDigests yet.
_pin_inventory() {
local baseref bdig
baseref="${SLUICE_BASE_IMAGE:-cgr.dev/chainguard/wolfi-base}"
bdig="$("$ENGINE" image inspect "$baseref" --format '{{ if .RepoDigests }}{{ index .RepoDigests 0 }}{{ end }}' 2>/dev/null || true)"
if [ -z "$bdig" ]; then
echo "[sluice] resolving the base image digest (pulling $baseref) ..." >&2
"$ENGINE" pull "$baseref" >/dev/null 2>&1 || true
bdig="$("$ENGINE" image inspect "$baseref" --format '{{ if .RepoDigests }}{{ index .RepoDigests 0 }}{{ end }}' 2>/dev/null || true)"
fi
printf 'base %s\n' "${bdig:-$baseref}"
current_inventory | grep -v '^base ' # drop current_inventory's own (maybe-digestless) base line
}
# `sluice lock --pin`: write ./sluice.pin, a committable replay manifest - the base pinned by digest
# plus every apk/npm/pip/gem/go/cargo name+version. `SLUICE_PIN=1` (M2) rebuilds converging on exactly
# these versions. Also refreshes sluice.lock from the same built image (they read one image, so they
# never disagree; the extra introspection is a no-op build + a second read). Fails closed on a hollow
# inventory or an unresolvable base digest - a pin that can't freeze its base is worse than none.
write_pin() {
maybe_build
local pin="$PROJECT_DIR/sluice.pin" inv base na
inv="$(_pin_inventory)"
printf '%s\n' "$inv" | grep -q '^apk ' || die "could not read the image inventory - refusing to write a hollow sluice.pin"
base="$(printf '%s\n' "$inv" | awk '$1=="base"{print $2; exit}')"
case "$base" in *@sha256:*) ;; *) die "could not resolve a base image digest - refusing to write a pin that cannot freeze its base (is the base image pullable?)" ;; esac
{
printf "# sluice.pin - pinned replay manifest for %s.\n" "$tag"
printf "# Rebuild with SLUICE_PIN=1 to converge on these exact versions. Honest scope: an apk pin\n"
printf "# fails CLOSED once Wolfi stops serving that version (rolling repo) - see docs/supply-chain.md.\n"
printf "# 'base' pins the image by @sha256 digest; each '<eco> <name> <version>' line pins a package.\n"
printf 'base %s\n' "$base"
printf '%s\n' "$inv" | grep -v '^base ' | LC_ALL=C sort
} > "$pin"
na="$(printf '%s\n' "$inv" | grep -c '^apk ' || true)"
echo "[sluice] wrote $pin (base pinned by digest + $na apk + npm/pip/gem/go/cargo versions)"
write_lock # keep sluice.lock in lockstep (same image, so the two agree)
}
# Drifted lines between ./sluice.lock and the live image inventory ("< lock / > current"); empty =
# in sync. Optional $1 = a pre-computed inventory (so doctor doesn't introspect the image twice).
lock_drift() {
local inv="${1:-$(current_inventory 2>/dev/null || true)}"
diff <(grep -vE '^#' "$PROJECT_DIR/sluice.lock" 2>/dev/null || true) \
<(printf '%s\n' "$inv") 2>/dev/null | grep -E '^[<>]' || true
}
# Classify raw lock_drift ("< old" / "> new") into sorted structured rows:
# "<op>\t<type>\t<name>\t<old>\t<new>", op = add|del|chg. $1 = pre-computed raw drift (else read fresh).
# Key = type+name+version so one name at two versions is del+add, not a bogus single chg (A7); apk
# carries its checksum into the value so a same-version rebuild renders a legible chg, not "1.0 -> 1.0" (A6).
classify_drift() {
local raw TAB; TAB="$(printf '\t')"
if [ $# -ge 1 ]; then raw="$1"; else raw="$(lock_drift)"; fi
[ -n "$raw" ] || return 0
printf '%s\n' "$raw" | awk '
{ type=$2;
if (type=="base") { key="base"; name="base"; val=$3 }
else if (type=="apk"){ key=type SUBSEP $3 SUBSEP $4; name=$3; val=$4 ($5==""?"":" " $5) }
else { key=type SUBSEP $3 SUBSEP $4; name=$3; val=$4 }
if ($1=="<") { oldv[key]=val; haveo[key]=1 } else { newv[key]=val; haven[key]=1 }
t[key]=type; nm[key]=name; seen[key]=1 }
END{ for (k in seen) {
if (haveo[k] && !haven[k]) printf "del\t%s\t%s\t%s\t\n", t[k],nm[k],oldv[k];
else if (!haveo[k] && haven[k]) printf "add\t%s\t%s\t\t%s\n", t[k],nm[k],newv[k];
else printf "chg\t%s\t%s\t%s\t%s\n",t[k],nm[k],oldv[k],newv[k] } }' \
| LC_ALL=C sort -t"$TAB" -k2,2 -k3,3 -k4,4 -k5,5
}
# Render structured drift rows (stdin) as aligned, colored +/-/~ lines. $1=err -> stderr-gated colors
# (the --check path renders to stderr; write_lock/--diff render to stdout).
render_drift_human() {
local g="$C_GRN" r="$C_RED" y="$C_YEL" x="$C_RST"
[ "${1:-}" = err ] && { g="$E_GRN"; r="$E_RED"; y="$E_YEL"; x="$E_RST"; }
awk -F"$(printf '\t')" -v g="$g" -v r="$r" -v y="$y" -v x="$x" '
{ op[NR]=$1; ty[NR]=$2; nm[NR]=$3; ov[NR]=$4; nv[NR]=$5;
if(length($2)>wt)wt=length($2); if(length($3)>wn)wn=length($3) }
END{ for(i=1;i<=NR;i++){
sym=(op[i]=="add")?"+":(op[i]=="del")?"-":"~";