sc check and sc editStatus: approved 2026-10-03 (“great go ahead”: every recommendation in section 14). Drafted by Claude in the session (your pick), from the roadmap, the M2 plan’s deferred list and probes on this VM (Appendix A).
sudo sc edit /etc/fstab opens your editor on a copy, checks the result before it replaces the file, and tells you in plain words when the edit would stop the machine booting or lock you out. You can fix it, save anyway, or quit; the real file is untouched until you save.sc check runs the same checks on the files as they are now, or on any saved version (sc check <id>).scd runs the check after it records a change made with any editor or tool, and puts a warning in the journal when the change added a problem.findmnt --verify, visudo -c, sshd -t, …) run on a scratch copy, plus a few rules of our own with a fixed explanation each. No model, no network.pwck -r exits 2, 8 of 121 udev rule files fail udevadm verify). So an edit is judged by what it adds: the old and the new version are both checked, and only new findings are blamed on the edit.netplan generate --root-dir still asks systemd to reload. Each validator form in this plan was run here first; netplan uses its generator binary directly.findmnt --verify misses what matters most and overstates the rest: it accepts a misspelt mount option, and it reports a missing disk as an error even with nofail. Our own rules add the severity.sc-m2 and sc-m1 keep reading the store.Goals. M3 is done when all of these hold, plus the sign-off in section 11.
sc edit FILE never replaces the file with a version that adds a blocker or error finding unless you answer “save anyway”.sshd or findmnt may be absent: the check says “no validator found” and our own rules still run. sc stays one static binary with no new dependency.sc edit gives exactly its rows (the state before, if not yet recorded, and one edit row), never an extra automatic row, as for restores in M2.go test ./... as user and as root, make race, static build, make m1-compat.Non-goals (not built in M3, not even as stubs): everything in section 13; the rescue path and sc status (M4); packaging (M5); any model (M7).
All run on fabricated scratch files; the real /etc was only read. Details in Appendix A.
| Consumer | Check-only form | Needs root | Time | Notes |
|---|---|---|---|---|
| fstab | findmnt --verify --tab-file F |
no (root adds the on-disk type check) | 20-100 ms | exit 1 on [E]; misses a misspelt option; [E] even with nofail |
| sudoers | visudo -c -f F |
no | 30 ms | exit 1, F:2:19: syntax error; quotes the line |
| sshd | sshd -t -f F |
yes, or -h KEY with any host key |
20-90 ms | exit 255, line 2: Bad configuration option; a drop-in checks alone |
| systemd unit | systemd-analyze verify F |
no | 0.2-0.7 s | exit 1; needs the unit’s file name; a stock unit already fails (man page lookup) |
/etc/default/grub |
sh -n F |
no | 10 ms | exit 2 |
grub.cfg |
grub-script-check F |
no | 10-30 ms | exit 1 |
| netplan | /usr/libexec/netplan/generate --root-dir D |
no | 10-30 ms | not netplan generate: it calls systemctl daemon-reload even with --root-dir |
| passwd, group | pwck -r P S, grpck -r G |
no for scratch | 15-30 ms | exit 2; the stock system already exits 2 (missing home dirs of system users); quotes the line |
| sysctl | sysctl --dry-run -p F |
no | 10-20 ms | exit 1; checked as root: the value did not change |
| udev rules | udevadm verify F |
no | 10-40 ms | exit 1; 8 of 121 stock files already fail |
Not usable as they are: nft -c (needs root and talks to the kernel), logrotate -d (exit 1 on a good file), apparmor_parser -Q (works; left for later).
cmd/sc check.go, edit.go, scope.go thin, as today
internal/check graph.go the file graph (default.graph, embedded)
check.go Finding, Severity, Checker, baseline diff
run.go the one place that starts a validator
rules.go rule ids, severities, explanations
fstab.go, sudoers.go, sshd.go, unit.go, grub.go, plain.go, …
internal/store Replace(): write a file and its row together (shares Restore's code)
internal/watch after a recorded row: hand the path to a checker goroutine
internal/check knows nothing about the store or the watcher: it takes a path and the bytes to check, and returns findings. That keeps it testable with fake validators.run.go); everything else calls it.One text file built into the binary, parsed like the scope (first matching line wins, same glob language):
# check NAME GLOB… these files are read by checker NAME
# apply NAME TEXT when a saved change takes effect
# mode GLOB OCTAL mode for a file sc edit creates (default 0644)
check fstab /etc/fstab
apply fstab at the next boot (now: systemctl daemon-reload, then mount -a)
check sudoers /etc/sudoers /etc/sudoers.d/*
mode /etc/sudoers.d/* 0440
apply sudoers at the next sudo
check sshd /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf
apply sshd at the next start of ssh.service (systemctl restart ssh)
check unit /etc/systemd/system/*.{service,socket,timer,mount,path,target}
apply unit after systemctl daemon-reload
check shsyntax /etc/default/grub
apply shsyntax after update-grub, at the next boot
check grubcfg /boot/grub/{grub.cfg,custom.cfg}
…
sc scope PATH (promised for M3 in the M2 plan) prints what SmartConfig knows about a path: recorded or excluded and by which scope line, tier, fingerprint-only, its checker, and when a change applies.type Finding struct {
Rule string // "fstab-source-missing"
Severity Severity // Blocker, Error, Warning
Path string // the real path, never the scratch copy
Line int // 0 when unknown
Text string // one sentence, ours; may name a device or option, never a secret
Raw string // the validator's own lines; shown by sc check and sc edit only
}
sudoers-syntax, sshd-invalid, …) with the severity in the table below.sc edit and scd check the previous content and the new content, and report findings present in the new and absent in the old. A finding’s identity is its rule plus its text with the line number left out.sc check shows everything it finds: there is no “before”.run.go)/usr/sbin, /usr/bin, /sbin, /bin only, never through $PATH. Not found: the check carries on with our rules and says no validator found (sshd).LC_ALL=C and a fixed PATH. Stdin is /dev/null.$SC_HOME/tmp/check-XXXX/ (0700, root; $SC_HOME is never under a watched root), under the file’s own base name, and is removed afterwards.sudo sc). They are only ever given files from the graph, which are root’s: nothing under a login’s .ssh has a checker, so no user-controlled content reaches a validator.| Checker | Files | Validator | Our rules (severity) |
|---|---|---|---|
| fstab | /etc/fstab |
findmnt --verify --tab-file |
fstab-source-missing: a device that does not exist, on a line without nofail or noauto, other than / (blocker: 90 s wait, then emergency mode, and root is locked on Ubuntu, though the M4 lab found that its sulogin still opens a console shell; PROJECT_LOG D6). The same with nofail: warning. fstab-root-source (error; Ubuntu most likely still boots, D6). fstab-fstype-mismatch (as source-missing; root only). fstab-option-typo: an option one or two letters away from a common one, e.g. defalts (as source-missing; findmnt accepts it). fstab-fields (error). |
| sudoers | /etc/sudoers, /etc/sudoers.d/* |
visudo -c -f |
sudoers-syntax (blocker: sudo refuses to run at all). sudoers-mode: a drop-in not 0440 root (error). |
| sshd | sshd_config, sshd_config.d/*.conf |
sshd -t -f |
sshd-invalid (blocker: the running server keeps going, the next start fails and is not retried; watch list). |
| unit | units in /etc/systemd/system |
systemd-analyze verify |
unit-syntax (error), unit-exec-missing (error), unit-unknown-key (warning). |
| grub | /etc/default/grub, grub.cfg, custom.cfg |
sh -n, grub-script-check |
grub-default-syntax (blocker: update-grub fails or writes a broken menu), grubcfg-syntax (blocker). |
| plain | files with no validator | none | nsswitch-no-files: the passwd: line names no files or systemd (blocker: users vanish). preload-missing-lib: a library named in /etc/ld.so.preload does not exist (blocker: every dynamic program prints an error or fails; the static sc still runs). flag-nologin, flag-sshd-not-to-be-run: the file exists (warning). hosts-no-localhost (warning). |
Second set, same framework, after the first set is reviewed: netplan (netplan-invalid, blocker over SSH), passwd and group (passwd-invalid, group-invalid), sysctl (sysctl-invalid), udev rules (udev-invalid).
Each rule has its explanation in rules.go: two to five lines, what it means, why it matters, how to fix it. sc check -v and sc edit print it.
sc check [PATH|ID]…$ sudo sc check /etc/fstab
SEVERITY FILE LINE RULE PROBLEM
blocker /etc/fstab 12 fstab-source-missing UUID=1111… is not a disk on this machine
1 blocker. sc check -v explains; sc log /etc/fstab lists the versions to restore.
sc itself failed (one line on stderr, as today).-v adds each rule’s explanation and the validator’s own lines.sc edit PATH$SC_HOME; a second sc edit says who holds it.$SC_HOME/tmp/edit-XXXX/<name> (0600) and run the editor: $SUDO_EDITOR, $VISUAL, $EDITOR, then editor, nano, vi. No shell.What now? (e)dit again, (s)ave anyway, (q)uit without saving [e]:
End of input (no terminal) means quit: a failing file is never saved unattended.manual, “before sc edit”); then one row with the new origin edit, intent sc edit, or sc edit, saved with 1 blocker.saved /etc/fstab as 3fa2c1 (before: 9b01de); takes effect at the next boot.sc scope PATHSee section 4. Read-only; works without root for the scope part.
sc check still finds the problem.T1 /etc/fstab: check: blocker fstab-source-missing, line 12 (sc check /etc/fstab)
at err for a blocker, warning for an error, notice for a warning.T1 /etc/fstab: check: ok again.first seen rows of the startup baseline (1,500 files at boot), restores and sc edit rows (already checked or deliberate), fingerprint-only rows, rows held by the rate limit until they are written.user_version stays 1.Store.Replace(path, data, mode, uid, gid, origin, intent), built from Restore’s write path (prepare, lock, stamp check, commit, row).edit. sc log already prints origins as they are; sc-m2 and sc-m1 read such rows as file rows (make m1-compat gains one case).The loop for every step is M2’s: WORKLOG says what the step is; code and tests; gofmt, vet, tests as user and root, race for timing code; one code commit; one worklog commit; push; CI green before the next step.
| # | Chunk | Commit | Tests |
|---|---|---|---|
| 1 | A | check: findings, rules, runner |
fake tool in a temp dir: timeout kills the group, output cap, missing tool, env and argv exact |
| 2 | A | check: file graph |
parse errors; every graph glob lies inside the scope; For(path) |
| 3 | A | check: fstab |
golden outputs from this VM; own rules on fabricated fstabs; real findmnt when present |
| 4 | A | check: baseline diff |
a finding that only moved lines is not new |
| 5 | B | sc check |
table, -v, exit codes, by id, no validator |
| 6 | B | store: Replace |
rows, refusals, kill between write and commit; with a watcher: no extra row |
| 7 | B | sc edit |
EDITOR is a script; unchanged; clean save; blocker then q, e, s; end of input quits; changed on disk; new file mode |
| 8 | C | check: sudoers |
syntax, mode rule |
| 9 | C | check: sshd |
with -h and a throwaway key when not root |
| 10 | C | check: units, grub |
scratch copy under the unit’s name; stock-unit noise is not new |
| 11 | C | check: plain rules |
nsswitch, ld.so.preload, flags, hosts |
| 12 | D | watch: check after a row |
new finding logged once; ok again; baseline rows not checked; a slow validator does not delay rows; queue bound |
| 13 | D | check: second set |
netplan through the generator binary (asserts no reload request), passwd/group, sysctl, udev |
| 14 | D | sc scope |
recorded, excluded by which line, tier, checker |
| 15 | E | scripts: accept-m3.sh, docs |
section 11 |
| 16 | E | sign-off runs, final review, tag m3 |
section 11 |
Gates. G1: before step 12, your OK that scd may run validators as root (question 3). G2: before any install or run on the real system, the M2 soak check is done and you say yes.
Tests never touch /etc. Every checker takes the file to read as an argument, so tests use temp dirs. Real validators are used when installed (exec.LookPath), else the case is skipped; the golden outputs keep the parsing covered in CI.
About 4 to 5 working days: chunk A one day, B one day, C one day, D one day, E and sign-off half a day plus your time. M2 took 5 days with a larger review load.
Owner scenario (the one for M3). You add a data disk to /etc/fstab with sudo sc edit /etc/fstab and mistype its UUID. Before anything is saved, sc says this machine would wait 90 seconds at boot and stop in emergency mode with root locked, and why. You quit; fstab was never broken. Then the same typo with nano: within seconds the journal has the warning, sc check explains it, and sc restore puts the file back.
sudo ./scripts/accept-m3.sh (in the VM, refuses to run elsewhere, undoes itself): a scratch $SC_HOME and its own test daemon as in M2; test files only under /etc/sc-accept.d; real validators on fabricated files; sc edit driven by a script as editor, through every answer; the scd journal lines.
Sign-off runs, each with your OK (after the M2 soak check and a fresh snapshot):
accept-m3.sh passes.sc check on the real system, read-only. Its findings on a healthy system are reviewed with you: each is a real problem or a rule to fix./etc/fstab (quit without saving) and a real sshd_config typo made with nano, then restored.m3.fstab-option-typo only fires near a known option.sc check never says “safe”.| Item | Now |
|---|---|
Validators; existence-flag alerts; sc scope PATH |
in M3 |
| Applied markers (“pending until update-grub”) | later; M3 only prints the apply text |
| Account-set grouping and set restore; PAM grouping | later; M3 checks passwd and group files one by one |
| apt/dpkg change sets | later (with the M5 hook file) |
| Active/inert labels, vendor-override detection | later |
| Inode flags, ACLs, xattrs, directory metadata | later (needs a store change) |
Secret redaction in sc diff and sc cat |
later |
/etc/alternatives chains, rc?.d links |
later |
Extra roots (/usr/local/etc, crontabs, …) |
later |
| Content-based secret detection (final review) | later |
| nft, logrotate, apparmor, PAM checkers | later, when a check-only form is proven |
“Later” is decided at M3 sign-off: either an M3 follow-up list, as for M2, or a milestone of its own.
Each has a recommendation, and the plan assumes it.
sc edit, sc check) plus the existence flags and sc scope; the other items of section 13 wait. Recommendation: yes.sc edit: ask, with “save anyway” as an answer, as visudo does; end of input quits. Recommendation: yes, rather than a hard refusal.sc-m2 as a fallback.| # | Date | Change | Why | Commit |
|---|---|---|---|---|
| C1 | 2026-10-03 | The graph has no with line. The built-in graph starts with its header only; each checker’s check, apply and mode lines are added with the step that builds it. |
Section 7 checks only the file that changed, so with would have been parsed and never read. Your OK (asked). |
95da13b |
| C2 | 2026-10-03 | fstab severities follow systemd-fstab-generator (A14): a missing device or misspelt option is a blocker only on a local filesystem without nofail or noauto; on /, swap and network filesystems it is an error; with nofail or noauto a warning. New rule fstab-verify (warning): a findmnt error sc has no rule for. fstab-fields is a line libmount ignores (fewer than three fields, or a non-numeric dump or pass field). findmnt’s missing-mount-point error is not reported. |
The generator requires swap from swap.target, which sysinit.target only wants, and network mounts from remote-fs.target; only local-fs.target fails into emergency mode. systemd creates a missing mount point. |
b665691 |
| C3 | 2026-10-03 | From the chunk A review: Finding has a Key (never shown) that tells apart findings with the same text; the runner returns stdout and stderr apart; fstab-option-typo fires only one slip from a common option, against a list of known options (mountopts.txt); findmnt’s type warning is ignored for compatible types and is a warning within the ext family; Report.Notes says when a validator did not really run. |
reviews/2026-10-03-m3-chunk-a.md: three findings could hide a line that stops the boot. | a2e325f |
| C4 | 2026-10-03 | sc check without the right to write $SC_HOME (a user, no sudo) puts its scratch copies in a private directory of its own under $TMPDIR (0700, removed afterwards). An argument is a row id when it is 4 to 64 hex digits, else a path (./name for a file named like an id). |
Section 5.3 put every scratch copy under $SC_HOME, which only root may write; /etc/fstab is world-readable and should be checkable without sudo. |
afa9403 |
| C5 | 2026-10-03 | From the chunk B review. sc edit: the edited copy is kept under $SC_HOME/tmp/kept-* after any failure and after end of input, and a leftover copy is kept, not removed; SIGINT and SIGQUIT are left to the editor while it runs; the old content is checked after the editor returns; the last line is two lines. sc check: exit 1 when a file could not be read; an id is 1 to 6 hex digits in any case when no file of that name exists (replaces C4’s rule). Replace: a new file after an unrecorded deletion gets a deleted row first; 8 MB limit. |
reviews/2026-10-03-m3-chunk-b.md: two findings lost the user’s edit. | a946f6f |
| C6 | 2026-10-03 | Chunk C. Rules added beyond section 5.4: unit-notice (warning: a remark about a unit that loads), nsswitch-invalid (blocker: an action glibc cannot parse makes it reject the whole file), sudoers-ignored (warning: a drop-in name sudo skips). sudoers-mode is an error only where sudo ignores the file, else a warning. nsswitch-no-files counts only files and compat as local (not systemd). sc check with no argument checks only files the scope records; sc check <id> leaves out rules about the file on disk (CheckSaved). The checkers were built by four parallel agents in separate worktrees (your call), integrated one commit at a time. |
Agent findings, Claude’s review of their work, and reviews/2026-10-03-m3-chunk-c.md; each rule was probed against the real tool or glibc. | 4a3451c, c588cdd, ff952e7, 7aeb144, 41c87bf |
| C7 | 2026-10-03 | New: sc check --as PATH FILE checks FILE as if it were at PATH (rules about the file on disk left out). The acceptance run (section 11) uses it to give every checker fabricated content as root with the real validators; scd’s check lines and sc edit are exercised on /etc/systemd/system/sc-accept.service, a unit that is never enabled, removed by the script’s EXIT trap. |
Section 11 kept test files under /etc/sc-accept.d, where no checker reads; writing a broken fstab, sudoers, netplan or passwd file for a test is not acceptable. --as is also useful on its own: check a candidate before copying it into place. |
c53180f |
| C8 | 2026-10-03 | From the chunk D review. passwd-root is a warning, not a blocker, for a missing or unreadable root line when nsswitch has systemd on its passwd line; a root line with another uid stays a blocker. netplan: when another netplan file could not be read, an error that may come from it is a note asking for root. sc scope looks at each root with lstat as scd does: a path under a missing, symlinked or non-directory root is not recorded, and one under a root sc may not look at says so. scd’s checker reuses the findings of its last check of a path as the version before, and a failed check of the version before no longer stops the check of the new one. |
reviews/2026-10-03-m3-chunk-d.md: nss-systemd supplies root on stock Ubuntu (reproduced: sudo still works), and the netplan blocker fired on a valid file for a user. | 2fb037e |
| C9 | 2026-10-03 | From the final review. New rules: passwd-no-admin and group-no-admin (the members of sudo and admin must keep their accounts and their membership), sudoers-no-rules (/etc/sudoers with no rule and no include). fstab looks for path sources (bind, image, swap file). Blockers now: nsswitch group, initgroups and shadow lines without files; sudoers-mode on /etc/sudoers itself; /etc/default/grub values with a space and no quotes, or spaces around =. fstab-verify takes the line’s severity, and x-systemd.automount makes a missing source an error. Errors now: a sudo or admin member that is not a user; preload-missing-lib. A validator cut short makes a report incomplete: scd logs no “ok again”, and sc check exits 1. scd checks restore and edit rows it sees, for “ok again” only. |
reviews/2026-10-03-m3-final.md: two high findings, files that lock Ubuntu’s owner out or stop the boot with no word from sc, each shown with the real tools in a private mount namespace. | 297fbc7, bb31f3e |
Inputs were fabricated files in a scratch directory (kept in ~/smartconfig-work/m3plan/); results from the real system are counts and exit codes only.
[E] lines; unknown /dev path exit 1; the option defalts exit 0, also as root; type xfs on an ext4 disk: [W] as root, nothing as a user (it cannot read the disk); a missing disk with nofail still exit 1 with [E].-c -f works as a user; exit 1 and sudoers.bad:2:19: syntax error followed by the offending line. The real system as root: 3 files parsed OK, all 0440 root.sshd -t -f fails with “no hostkeys available”; with -h and a throwaway key it works. Unknown keyword and bad value: exit 255 with the line number. A drop-in file alone checks clean.ExecStart binary and a broken section header; a scratch copy must carry the unit’s file name. The 14 real units in /etc/systemd/system together exit 1 because of one stock unit’s man page reference.netplan generate --root-dir D generated into D/run and then tried systemctl daemon-reload (refused only because it ran as a user). /usr/libexec/netplan/generate --root-dir D gave the same result and messages with no reload request; netplan get --root-dir D all also parses, in 250-380 ms.grub-script-check exit 1 on an unclosed menuentry; sh -n exit 2 on an unterminated quote in a default/grub-style file.--dry-run -p F exit 1 on an unknown key or a non-assignment line. As root with a file setting vm.swappiness to 61: printed the line, the value stayed 60.nft -c -f fails as a user (“cache initialization failed”) and reads kernel state as root; logrotate -d exits 1 on a good file; apparmor_parser -Q -K works (exit 1 with a line number) but is left for later.$EDITOR and $VISUAL unset; /usr/bin/editor is nano; vi is vim.tiny.SYSTEMD_FSTAB on a fabricated file, output into a scratch directory): a plain ext4 line lands in local-fs.target.requires with Requires=systemd-fsck@…; with nofail in local-fs.target.wants; with noauto nowhere; swap in swap.target.requires; nfs in remote-fs.target.requires. sysinit.target has Wants=swap.target local-fs.target; local-fs.target has OnFailure=emergency.target. findmnt reports a line with one or two fields, or a non-numeric fifth field, as parse error at line N -- ignored; three fields parse.sudoers.d (one is its README), 0 sshd drop-ins, 2 netplan files, 14 service units in /etc/systemd/system, 5 udev rule files, 11 sysctl.d files, 31 pam.d files, 114 entries in /etc/apparmor.d.