Generated from cat-harness/skills/conduct/security/security.md — do not edit here.

✎ Edit this page’s source

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

boundarythe hazardmachinerystate
${{ }} → a run: or script: blockthe 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 bandscripts/check-workflow-injection.ts + baseline + 2 test filesgated (1wef, j0zs); two surfaces with two remedies, with:/env:/if: still ungraded
an external id → a pathjoin(base, external) escapes the store; mkdirSync + writeFileSync make it a write primitivepath-containment, src/core/safe-path.tsguarded; narrowly gated — server-path-sinks.test.ts, a source ratchet (6bhf)
a shell string → a processexecSync("cmd " + value) gives the shell a programinjection-boundariespartly (execFileSync at the fixed sites)
an archive → a filesystema LINK member writes through to its target, which ..-refusal does not covermember-TYPE whitelist (tar tvzf, accept only - and d) at the one call site, tar-member-guard.test.ts against real archivesclosed at one site (6bhf)
a .po catalogue → a rendertranslated content is authored elsewherescripts/translation/translation_security.pyexists, unaudited here
a hand-over → a modela 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 originguarded 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 instancea mounted harness’s skills steer agents and its code runs, so an untrusted pin is a supply-chain compromisebun 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 → HTMLXSS in a rendered surfacebean q2wm, declared XSS hints on tools and skillsopen
a third-party action → a runnera moved tag or rewritten branch runs new code with the job’s token and secretsbun 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

  1. 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.
  2. Is there already a helper? src/core/safe-path.ts is the path one. It got there because resolveWithin sat in scripts/serve-rendering.ts, complete and correct, while three HTTP handlers did the wrong thing — the exact shape 1wef names: “somebody had understood this hazard exactly. Nothing checked it, so the correctness was one edit from gone.”
  3. 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

processstep(s) that name it
Publishing the docs site, and keeping the previews aliveRun the release security gate [security:gate]

folio-assistant — code under the Apache License 2.0, documentation under CC BY 3.0. Generated schema and API references are rebuilt from source on every push to main.

deployed b08c961 · 2026-10-23T23:11:09Z