Generated from
cat-harness/skills/conduct/security/security.md— do not edit here.
Security — one question, asked at every boundary
This value came from outside. What may I do with it?
Everything in this package is that question at a different boundary. The split
is deliberately not by attack name, because the same untrusted string is
command injection at a shell, path traversal at a join, and XSS at a template —
three names for one fact, and filing the rule under each of them is how a
correct guard ends up in one file while three call sites hand-roll it.
The rule that unifies them: refuse, never repair
A check returns a refusal, not a cleaned-up value. This is measured, not
stylistic. fuzm: the staging slug pipeline REPLACED disallowed characters,
so input .. survived as output .., and a branch of --- collapsed to the
empty string — which made rm -rf "pages/STAGING/$SLUG" name every preview.
A repair silently changes which artefact is named. A refusal makes the caller
decide, and only the caller knows whether that is a 400, an exit 1, or a skip.
And check by VALUE, never by provenance. feature-staging.yml re-runs its
slug guard after the value crosses a job boundary, and says why in four lines:
it was validated once, and “where it came from” is not a property you can read
off a variable.
The boundaries, and what guards each one today
| boundary | the hazard | machinery | state |
|---|---|---|---|
${{ }} → a run: or script: block | the value is substituted into the script TEXT before bash or V8 parses it, so a quote is enough — and inside a JS template literal ${…} is EVALUATED, so it need not even close one. A step OUTPUT carries whatever the step put in it, so the gate resolves provenance rather than trusting the band | scripts/check-workflow-injection.ts + baseline + 2 test files | gated (1wef, j0zs); two surfaces with two remedies, with:/env:/if: still ungraded |
| an external id → a path | join(base, external) escapes the store; mkdirSync + writeFileSync make it a write primitive | path-containment, src/core/safe-path.ts | guarded; narrowly gated — server-path-sinks.test.ts, a source ratchet (6bhf) |
| a shell string → a process | execSync("cmd " + value) gives the shell a program | injection-boundaries | partly (execFileSync at the fixed sites) |
| an archive → a filesystem | a LINK member writes through to its target, which ..-refusal does not cover | member-TYPE whitelist (tar tvzf, accept only - and d) at the one call site, tar-member-guard.test.ts against real archives | closed at one site (6bhf) |
a .po catalogue → a render | translated content is authored elsewhere | scripts/translation/translation_security.py | exists, unaudited here |
| a hand-over → a model | a sub-agent report, delegated prompt, tool result or third-party comment carries an instruction the receiver follows because it trusts the sender (protocol pivoting) | bun run cat handover:screen (src/core/handover-screen.ts): field by field over a declared schema; control fields and undeclared fields refused, data fields quarantined; fenceUntrusted labels the origin | guarded and wired (cztn): every in-code site where foreign text reaches a model calls guardUntrusted or screenHandover; the sites and the deliberate gaps are in zero-trust-handover §”Where the screen is wired” |
| a remote graph → this instance | a mounted harness’s skills steer agents and its code runs, so an untrusted pin is a supply-chain compromise | bun run cat mount:remote (Tool remote-mount) refuses a mount that is neither consented for its exact pin nor signed in a declared trust network (schemas/mount-trust.ts); a signature with no verifier is could-not-determine; --staging needs neither (owner, 2026-10-07) | gated at the mount (ieum); signature verification (e.g. GDHCN) not built |
| content → HTML | XSS in a rendered surface | bean q2wm, declared XSS hints on tools and skills | open |
| a third-party action → a runner | a moved tag or rewritten branch runs new code with the job’s token and secrets | bun run cat actions:pin (pin-actions.ts) pins to a full commit SHA; security:gate blocks on any unpinned action except in a staging-only workflow that holds no write token and has no pull_request_target trigger (stagingExempt; owner, 2026-10-07: “unpinned on staging”, then “pin write-token workflows”) | gated (ieum, 1ygp L4.1): the counts are what bun run cat security:gate reports now |
Where to look when adding a check
- Is the boundary in the table? Then the machinery exists — extend it. Adding a second guard for a guarded boundary is how they drift apart.
- Is there already a helper?
src/core/safe-path.tsis the path one. It got there becauseresolveWithinsat inscripts/serve-rendering.ts, complete and correct, while three HTTP handlers did the wrong thing — the exact shape1wefnames: “somebody had understood this hazard exactly. Nothing checked it, so the correctness was one edit from gone.” - Can a gate hold the property? A hand-maintained invariant drifts and the
symptom of forgetting is invisible (
tyyc). A guard without a gate is a guard with an expiry date.
What this package does NOT own
rm -rf in this repository is already well defended, and that is a measured
finding rather than an assumption — all 42 rm -r* lines carrying an expansion
were read for bean 6bhf. All four staging deletion sites are guarded by value,
and 14 of 16 shell files carrying such a line run set -u. The hazard the owner
asked about is real in general and is not, today, where this repository is open.
The open surface was the TypeScript I/O layer.
Deletion policy is not security. Whether an agent may remove a durable
artefact at all is
deletion-requires-confirmation,
and it governs cases where the path is perfectly safe and the deletion is still
wrong.
Processes that run this skill
| process | step(s) that name it |
|---|---|
| Publishing the docs site, and keeping the previews alive | Run the release security gate [security:gate] |