Commit Graph

11 Commits

Author SHA1 Message Date
null 876f09f488 fix(tools): doc-triggers found the repository by depth and left it
ROOT was `Path(__file__).resolve().parents[3]`, which is correct only while the
script sits at its template home, docs/architecture/scripts/. TOOLS.md tells an
adopting project to take scripts one at a time into its own scripts/, and from
<project>/scripts/doc-triggers.py that expression resolves to the *parent of the
project* -- outside the repository entirely.

The failure is silent and reads as a pass. main() opens with a `not DOCS.is_dir()`
guard that prints and returns 0, so an adopting project got exit 0 and one line
naming a directory two levels above the code it was asked about. The check that
enforces "update the triggered documents in the same commit as the code" had
quietly stopped running, in exactly the projects that took the template's advice.

That is the failure GUARDS.md opens with -- a guard that cannot fail is worse
than no guard, because it is trusted -- landed on the tool that polices the
documents. It could not be caught by running it here, because here parents[3] is
right; it takes a copy at the documented location to see it.

The root is now found rather than assumed: walk up from __file__ for a directory
holding both docs/ and .git, then either alone, then `git rev-parse
--show-toplevel`, then give up to the script's own parent. Both directories are
required before docs/ alone so that a repository vendoring a docs/ in some
subdirectory does not anchor on it.

Reproduced in a scratch repository with the script at scripts/, a document
governing src/**, and src/a.py staged: before, "no docs/ directory at <tmp>/docs";
after, the document and its trigger. The in-place layout is unchanged and still
fires DOC_TRUST_MAP.md, TOOLS.md and architecture/README.md.

Exit status is still always 0. This is a prompt, not a gate, so the fix belongs
in root resolution and not in the exit code.

closes #17

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:39:13 -05:00
null abf7315aa0 fix(scaffold): new projects were missing two documents they reference
docs/data/README.md links to `img/README.md` for the dimensions, weights and
magic-byte rule of the three marks. scaffold.sh never copied it: DOCS did not
list it and DIRS created docs/data/img empty. Every scaffolded project therefore
started with a broken link in a required document, a doc-claims failure on its
first run, and no copy of the spec the link promises.

The omission looks like a misreading of the script's own rule. Its header says
it does not copy branding and that the template's docs/data/img is not a source
-- both true of the three .webp marks, which must not be invented because a
placeholder that looks deliberate outlives the issue that would have replaced
it. img/README.md is documentation about those files, not one of them.

Verifying that fix surfaced a second, plainer one: docs/architecture/GUARDS.md
was never scaffolded either, while being referenced by TOOLS.md,
DOC_TRUST_MAP.md and architecture/README.md twice. It is a document, not a
script, and nothing argued for leaving it out.

A scaffold now writes 19 files, and a freshly scaffolded project no longer names
a document that is not there.

This is the fourth instance here of one shape -- a document naming a path that
is not present. The others were docs/planning/FUTURE.md in the batch ledger, an
Exempt: line inside a code fence, and docs/data/logo.webp as an example of where
NOT to put an asset. The first three were wrong in the template; this one was
correct in the template and wrong in every copy of it, which is why running
doc-claims here never caught it and scaffolding into a scratch directory did.

closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:34:12 -05:00
null bbf2a0a1ac feat(security): preflight.sh distinguishes present from in force
Run against its first real target the script reported "ok
strict-transport-security present". The response carried two of them:

  strict-transport-security: max-age=63072000; includeSubDomains
  strict-transport-security: max-age=63072000; preload

RFC 6797 section 8.1 -- more than one and the agent MUST process only the first
-- so what was in force was includeSubDomains without preload, and preload had
never once applied while the headers read, to a person, as though the site were
preload-ready. Two layers each adding their own is all it takes, and the second
is discarded in silence.

Each security header is now counted, and more than one is a finding naming the
directives that actually survive.

Two details that each took a wrong answer to get right, both the same class of
error the check exists to catch -- a tool answering confidently and wrongly:

- The value comes from the FIRST occurrence of the FINAL response block. Using
  the last named the second header as the one in force, which is precisely
  backwards, and curl -L concatenates every hop so an unscoped search quotes a
  redirect's copy rather than the page's.
- It is quoted from the original headers rather than the lowercased copy used
  for matching. Reporting `includesubdomains` to somebody who wrote
  `includeSubDomains` shows them a value they never sent.

Verified against a local server serving each shape, and against the origin that
prompted it, where it now names 'max-age=63072000; includeSubDomains' as in
force -- matching the wire byte for byte.

closes #12
2026-08-17 23:30:37 -05:00
null 9667b46585 feat(security): preflight.sh, the live-URL checks
Four things auditors of applications of this kind report seeing over and over,
each of them mechanical: a header that is absent, a scheme that is plain, a
login that answers a thousand guesses, a reset form that confirms which
addresses have accounts. None needs understanding to be checked, which is why
they belong in a script rather than a page somebody re-reads before a release
and then does not.

*(precautionary)* -- none of it has bitten a project here. The checks are cheap
and the evidence is somebody else's.

**Passive by default.** A bare run sends two GETs and could not be mistaken for
anything. Rate limiting and enumeration are behind --auth, because one of them
deliberately generates a dozen failed authentications.

**It refuses any host but its configured origin.** There is no URL argument that
can point it elsewhere: the target is PREFLIGHT_ORIGIN, and a URL on the command
line must match it. status.sh makes this argument for having no --host flag;
here there is more at stake, since a mistake there reads the wrong machine and a
mistake here hammers somebody else's login form from your address. The login and
reset paths are configured too, never guessed -- a POST to an assumed /login on
the wrong app posts to whatever is actually there.

Verified: exit 2 unconfigured, for a foreign host, and for an unreachable one;
exit 0 for --dry-run; exit 1 with the finding named. Run passively against
privacyllc.dev it correctly reported a strong CSP, a framing policy and HSTS,
and found that plain http answers 200 with the full page rather than
redirecting -- which is the class of finding this exists for, on its first real
target.

closes #9

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:14:06 -05:00
null 6d7a1e5eca feat(ops): controls.sh, which controls this project actually has
The question that decides whether a system can be depended on is not "does it
work" -- a demo answers that -- but which controls are present, asked before
something goes wrong. verify.sh reports which checks ran just now; backup.sh
proves one dump; the Command Center reports documents and tracker labels. None
of them says whether this project has backups AND has ever restored one AND has
somewhere errors go AND has an environment that is not production.

Four states, because flattening them is how a report starts lying:

  measured   observed here -- a file with a date, a variable that is set, a
             command that answered
  declared   asserted in configuration, checked for shape and not for truth
  n/a        the project said the control does not apply. A library has no
             uptime; saying so is an answer, not an omission
  unknown    expected and undeterminable. Never rendered as absent, because
             "I could not tell" and "it is not there" send people to different
             places

Reads BACKUP_DIR, BACKUP_NAME, HEALTHCHECK_BASE_URL, STATUS_HOST and
STATUS_CONTAINER from the scripts that own them, so the two cannot disagree
about which project this is. Writes nothing: a committed CONTROLS.md saying
"backups: ok" is a description of current state in a document, which is what the
batch ledger was and why it was archived.

Verified: exit 2 when nothing is declared and when a control name is unknown;
exit 0 for a library that declares only what applies, with seven n/a rows; exit
1 with three absent and one unknown; --quiet showing only rows needing
attention.

One bug that testing found and reading would not. GNU date parses relative
English, so `CONTROLS_LAST_RESTORE="last tuesday"` returned a real timestamp and
a plausible age -- a restore date the script invented. The shape is now required
before date sees it, and prose becomes unknown rather than a number.

closes #10

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:11:52 -05:00
null 0e9b5c482a fix(secrets): the scanner printed the credential it found
The report truncated each line to 120 characters and redacted nothing, so a
credential shorter than the budget was printed whole -- into the terminal
scrollback, the CI log, and wherever that log is shipped. It applied to every
mode, which meant a real leak caught by the pre-commit hook was also a real leak
printed to a terminal. The comment above it claimed the match was never echoed
in full; it was corrected to describe the behaviour in the previous commit, and
this changes the behaviour instead.

The match is now masked before truncation. \001 is the substitution delimiter,
as a real control byte rather than the literal backslash-zero-zero-one a
double-quoted "\001" produces -- that first attempt made sed take `\` as its
delimiter and silently substitute nothing, which looked exactly like working
code. These patterns contain both / and |, so either would end the expression
early.

Widening the JWT pattern was part of the same fix, not a separate improvement.
Masking removes exactly what the pattern matched, so `eyJ[A-Za-z0-9_-]{10,}`
redacted the header and printed the payload and signature next to it -- and
those are the token. It now matches all three segments. A pattern that
under-matches is a pattern that half-prints the secret.

Verified in --built and --staged: a planted JWT and a user:pass@host URL are
each reported with file and line, and neither planted value appears anywhere in
the output.

closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:08:57 -05:00
null e000f53883 feat(ops): restore-check.sh, the other half of backup.sh
backup.sh says it outright -- it verifies the artefact, only a restore verifies
the backup -- and its header names this script as the missing half, with the
pg_restore command it should run. This is that command with the parts that stop
it being dangerous.

A dump pg_restore --list can read is a file with a table of contents, not a
database. Between those sit every reason a restore fails on the day it is
needed: a missing extension, an owner that does not exist, version skew, a dump
of the wrong database that reads perfectly. And the number nobody has and will
want badly: how long it takes. During an incident that decides whether you
restore or fail over, and it is unknowable from the file size. Printed every run.

**The dangerous part.** pg_restore --clean issues DROPs, and pointed at
production it obeys immediately and irreversibly. Handled by never accepting a
target: there is no --database flag, because naming the database is the mistake.
The script creates `restorecheck_<epoch>_<pid>`, restores into that, and drops it
from a trap so an interrupted run leaves no copy of production data behind.
Same argument status.sh makes for having no --host flag.

Shares BACKUP_DIR, BACKUP_NAME and BACKUP_MIN_TABLES with backup.sh rather than
taking its own, so the two cannot disagree about which series belongs to this
project.

Proved against a real PostgreSQL, not asserted -- GUARDS.md section 1:

  exit 0  a real 3-table dump, minimum 1
  exit 1  minimum raised to 99; a dump truncated to 2000 bytes; a zero-byte dump
  exit 2  unconfigured (naming the missing value one at a time); server
          unreachable
  exit 0  --dry-run, always, contacting nothing

Two things that testing found and assertion would not. Every scratch database
was dropped, confirmed by querying pg_database afterwards. And --dry-run could
exit 1 on an empty dump, because the emptiness check ran before it; a mode whose
exit code depends on the state of the data is not a dry run, so the check moved
below and the dry run now notes the emptiness in its plan instead.

closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:07:36 -05:00
null 29864ccc0a feat(secrets): --built, because the repository is the wrong place to stop
--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.

Two tiers, because one would have been useless:

  findings (exit 1)  eyJ, service_role, apikey=, plus every pattern the other
                     modes already use
  noted (exit 0)     NEXT_PUBLIC_, VITE_, REACT_APP_, anon

The second tier is printed and fails nothing. Those prefixes mean "deliberately
shipped to the browser", so failing on them would be a permanently red gate, and
a gate that is always red is one everybody has learned to ignore. But a Supabase
anon key is safe exactly as far as row-level security makes it safe, and knowing
it is out there is the input to that judgement rather than a substitute for it.

eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.

Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.

One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.

closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:59:39 -05:00
null 2dca89e635 feat(docs): OPERATIONS.md, the runbook that did not exist
The template shipped scripts to back up, deploy, check health and read the
deployed version, and no document saying where errors go, what alerts, who
receives it, or what to run first when it is down. Grep across docs/** found
zero mentions of error tracking, observability or database restore.

Five sections. Where errors go, with the distinction that matters at 3am --
healthcheck.sh answers "is it up", error tracking answers "is it working", and a
service returning 500 to everything is up. What alerts and to whom, naming a
person rather than a channel nobody owns. Backups, whose last row is the date of
the last verified restore, because a backup nobody has restored is a guess.
Rate limits and cost ceilings, *(precautionary)*. And an ordered "it is down,
what now" where every step is a command that changes nothing.

Marked *(only for a deployed service)*, with the instruction to delete rather
than keep the headings unanswered: an empty runbook reads as one nobody wrote,
which is worse than one that never applied.

scaffold.sh now lays it down (17 files, 0 skipped) and DOC_TRUST_MAP.md points
at it from both tables. Two prose counts in scaffold.sh's header said
"thirteen documents" and were already stale; they no longer carry a number,
since a count in prose beside a list in code drifts the moment the list grows --
which is what just happened.

closes #6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:57:20 -05:00
null 912c470bbb docs(template): the owner is _null
README.md's status header named a person; it now names _null.

healthcheck.sh's crontab example hard-coded one operator's home directory in
two lines. Those are now $HOME, rather than a literal /home/_null, because a
template copied into every project should not carry anyone's home path and an
invented one would be a path that does not exist -- which this tree refuses
everywhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:47:56 -05:00
null 6965915dbd chore(repo): put the template under version control
The basis for every project here was itself unversioned: no .git, no remote,
no history. Changes to it had no diff and no revert, and two of its own guards
could not run at all -- doc-claims.sh and doc-triggers.py both read git
history, so the script written to catch documentation drift could not be run
against the documents that define drift.

This is the tree as it stands, including work that until now existed only as
loose files on disk: WORK_CYCLE.md, TOOLS.md, the Portainer image-line fix in
deploy.py, the status vocabulary corrected to the four words the conformance
checker actually enforces, the Exempt: mechanism documented, and the Forgejo
instance named in README.md.

secrets.sh --tracked reports one candidate, migrate.sh:480. It is the comment
documenting the three Postgres credential shapes that script redacts, with
literal placeholders, and it is left alone deliberately: GUARDS.md section 2
is that a source-grep guard must tell code from the comment about code, and
deleting an explanation to quiet a scanner is the failure it names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 22:44:26 -05:00