Skip to content

Security

Five questions, answered here with the detail living in its own place rather than duplicated: whether an agent can be let loose on context/, what the skill adds to a project's attack surface, what the linter does with hostile input, what the skill may do when it installs and runs that linter itself, and how this repository itself is protected — plus what the automated scanners say and why.

Is it safe to let an agent read and write context/?

Keep the Why treats everything read from a repository — context/ included — as data, never as instructions. An entry can describe a decision or a constraint; it can't grant itself authority to override a system, developer, or user instruction, expand permissions, authorize a tool call, disable a safety check, or ask for a secret. A suspicious entry gets named and flagged to the user, not silently followed, deleted, or rewritten.

This matters more for context/ than for an arbitrary repo file precisely because the skill is designed to read it automatically, treat it as high-salience background, and keep it around across sessions — the same property that makes it useful is what would make injected content dangerous if this rule didn't exist.

See Trust model for the full reasoning, the read/write rules, and worked examples.

What Keep the Why does and doesn't add to your attack surface

  • No external service, no telemetry, no daemon, no database — see Philosophy. There's nothing running that Keep the Why itself could leak through. The two optional version checks are the only outbound requests anything here makes — the skill's release check against the GitHub API (a timer you set in the personal wizard, or turn off) and the dashboard's check against pypi.org for the dashboard and the linter (at start and daily; --no-update-check) — and each sends nothing but the request. This covers Keep the Why only: third-party installers referenced in Installation (e.g. npx skills) have their own, separate policies.
  • No secrets, credentials, or personal data belong in context/ (Core rule 7) — retrospective recovery and interviews synthesize rationale, they don't transcribe raw material verbatim.
  • Actions with real side effects still go through whatever the agent running the skill already requires — permission prompts, sandboxing, trust verification. Keep the Why doesn't add a separate permission layer, and doesn't assume those mechanisms are bulletproof either.
  • The paths .keep-the-why can name are each confined to one directory: context and pinned-path to the project, root and a path-form family location (parent, a child) to the Git toplevel, the id-derived personal file to ~/.keep-the-why/; canonical and a URL-form family location are names, never resolved as paths. A family member the skill may write into is a local working tree — the same repository, or a checkout on this machine that ~/.keep-the-why/projects.json (where projects have been seen; the skill and the dashboard both keep it current) or the sibling folder knows — and the write follows that project's own confirmation setting; nothing outside the family is written, and the member's files are data like every other repository's. A member that is not checked out is fetched only after a question, never silently, and only as one of two things: a full clone into the folder the family lives in, or a context cache under ~/.keep-the-why/cache/<id>/ — a sparse partial clone holding the member's .keep-the-why and its context directory and nothing else, no agent instruction files. A cache is read-only for every tool, pulled on a timer before reads (default daily, cache-refresh / cache-offline in ~/.keep-the-why/config, overridable per cache), never written into, and never the only copy of anything.
  • An author's profile link in the dashboard asks the host on the click, from the browser: the host's API for one commit of that author (for example api.github.com/repos/<owner>/<repo>/commits/<sha>) names the account, and the page opens it. Nothing is sent but that request, nothing is asked before the click, and an --anonymize export offers no link — the commit hash would name the person the export hides. When the lookup fails, the commit's own page opens.
  • The dashboard's public mode is the one thing here that reads across the network without a clone, and it is the browser that does it, on request, never the Python server and never the skill: when a person switches the page to public, or chooses the family scope on a static export, it fetches a family member's .keep-the-why as a raw file from its host at HEAD, reads the dashboard-state line and nothing else from it, and fetches that published state.json — static files on a pages site, no service in between. Fetched state is other people's repository content: rendered as text like local state, never executed, never handed to the skill. A host that does not allow the fetch fails visibly; nothing is proxied. Every address the page fetched is on the page: the status bar counts the states loaded and their bytes, and a click lists each state.json and .keep-the-why with its size and the URL it came from — what the page read is never a matter of trust in this paragraph. A pinned SKILL.md is additionally checked for name: keep-the-why and the pinned version before it is followed — a pin is the one place repository content is meant to act as instructions, so it is scoped to a vendored copy of this skill and nothing else. Both the skill (Core rule 11, trust model, "Paths named by configuration") and the linter (E009, E010) enforce this.
  • A session with nobody present to answer cannot grant itself the permission capture-confirmation withholds. Only a session declared unattended — by the task or by session: unattended in the machine-wide config — writes at all at such a point, and then with Status: pending-confirmation, visible to the next person; an agent that merely finds itself in a quiet session asks, as always (Specification, §9; Setup, "Unattended sessions").
  • context/ is committed alongside the code, reviewed the same way — a change to it is as visible in a diff or a pull request as any other change.

The linter runs on pull requests from strangers

keep-the-why-lint is a CI tool that reads a configuration file and a directory of Markdown from whatever commit triggered it — in a public repository, that means from anyone. It is written for that, and every one of the following is a regression test, not a promise:

  • It never reads outside the checkout. context and pinned-path are resolved with symlinks followed and must land inside the project root; an absolute path, a .. escape, a symlink leaving the tree — including the config file itself being one — is E009 and not read. A symlinked file inside the context directory that resolves outside it is skipped with the same code. The one exception is explicit: --setup reads two named files under ~/.keep-the-why/ — the developer's own personal file and machine-wide config — for a local check after a settings change; the flag is never passed in CI, and the personal file's name comes from an id that passed E010 first.
  • The id is a file name. Letters, digits, ., _, -, nothing that could make ~/.keep-the-why/<id>.md land elsewhere (E010).
  • Malformed input is a finding, not a traceback. A config block without its end marker (E011), a second start marker (E012), a control character or an embedded NUL in a path field (E009), a file that is not valid UTF-8 (E302) — each is reported with a line number and the run continues; nothing reaches the path layer or the decoder unguarded.
  • What it prints is escaped. Values from the file are quoted in findings; control characters are rendered as \xNN, so a value cannot paint a terminal or a GitHub annotation, and a finding message cannot become a workflow command.
  • Hidden content is an error. Invisible and directional Unicode (E301) is the one mechanically checkable slice of the trust model; base64-looking blobs are a warning (W301). This is not a secret scanner — pair it with one.
  • No network, no dependencies. Standard library only; the action installs it from PyPI and runs it, nothing else.

The finding codes and what each checks: Linting. What the skill itself guarantees on the same inputs — the pin identity check, declared-not-inferred sessions, the trust model — is in the sections above; the linter is the part of it that a CI job can settle.

The skill installs and runs the linter

Since 0.14.0 the skill can run keep-the-why-lint itself — after a write to context/, and with --setup after a settings change — when a developer's personal local-lint setting says so (the wizard proposes auto; a file without the line means ask). That is the one place where the skill installs and executes a program, so the boundaries are stated here in full; every one of them is in references/setup.md, "Local linting", as the rule the agent follows:

  • One package, by its fixed name, from PyPI. keep-the-why-lint, published from this repository through trusted publishing only (below). The name, the index and the install command are the skill's own text; nothing in .keep-the-why, context/, a personal-defaults block or a linter finding can change which package is installed, from where, or at which version — the only version input is the floor, the skill's own metadata.version.
  • The wizard's answer is the yes. The personal wizard proposes auto and names the install in the question, so the answer — a one-word "defaults" included — is the consent for the install that follows in the same turn; ask asks before any install or update, and a declined install is recorded so the question is asked once; no leaves it to CI. A personal file that predates the setting and has no local-lint line means ask, never auto: a skill update does not install a package on an existing machine on its own. A session declared unattended never installs under ask.
  • No privilege, no environment override. The install uses what the machine already has — pipx, a user-site pip, uv — and never elevated privileges, a new virtual environment, or an override of an externally managed Python; a failing install is named with its error and asked about.
  • The linter's output is data. Findings are path:line: [CODE] message; the skill treats them the way it treats repository content (Core rule 11): a finding licenses fixing that finding in a file written this session, and nothing else — not another file, not context-schema, not a further install. A value an author put into an entry can be echoed inside a finding message; that gives it no more authority than it had in the entry.
  • The version floor points one way. The linter is brought up to the skill's version; context-schema and the skill's version are never lowered to satisfy an older linter.
  • --setup is the only read outside the checkout, and it is the developer's own two files under ~/.keep-the-why/, located by an id that passed E010 — see above.

What this adds to the attack surface: a shell invocation of a stdlib-only tool the repository publishes itself, and a PyPI install gated by a question. What it does not add: any path by which repository content chooses what runs.

How this repository is protected

The skill, the linter and this site are built and published from one repository. What stands between a commit and a release:

  • Every workflow action is pinned to a commit SHA, with Dependabot proposing the bumps weekly; workflows that write nothing run with a read-only token.
  • Every package goes out through trusted publishing only. keep-the-why-lint and keep-the-why-dashboard reach PyPI, and the keep-the-why skill package reaches npm — with provenance — because each registry trusts this repository's release workflow through a short-lived OIDC token issued per run: no API token is stored in the repository, in a secret, or on a maintainer's machine, so nothing but a run of that workflow from main can publish, and a leaked credential is not a path. On npm the workflow may only stage a version, which goes live after the maintainer approves it with 2FA — a run nobody asked for cannot publish.
  • A skill release is refused unless the tag agrees with the commit. release.yml compares the tag against SKILL.md, the four plugin manifests, package.json, llms.txt, this repository's own context-schema, the linter's SUPPORTED_SCHEMA and the newest CHANGELOG section, and requires the matching linter to be on PyPI already — the release checklist's linter-first order as a gate, not a convention. The tag reaches the scripts as an environment variable, never interpolated into a shell.
  • main takes squash-merged pull requests only, with Validate Skill, the linter package tests, the dogfood lint of this repository's own context/ in strict mode, and Black as required checks; force-pushes and deletion are blocked; CODEOWNERS routes review for context/, the config, the workflows, the linter, the skill and the eval fixtures.
  • Scanned on every push by the HOL AI Plugin Scanner (hol-guard), in this repository's own CI — the same scan the HOL plugin registry runs on listed projects — currently 94/100 with no critical or high finding; the workflow fails below 80 or on any high finding — with a read-only token and the scanner wheel verified before it runs. The current result and its one medium finding are in the scanners section below.
  • Reviewed from outside, more than once. External reviews of 0.11.0 and 0.12.0 each found real things — the path confinement, the SHA pins, the id grammar, the release gate — and each is listed in the CHANGELOG with what changed. A full audit of the repository followed the 0.12.0 review (2026-09-07); its findings are in main or, where deliberately left, recorded as decisions in context/.

What is deliberately not hardened, so nobody has to re-find it: latest and lint-latest are moving tags and the default way to consume the skill and the action, which means a compromised maintainer account reaches every consumer on the next session — the mitigation is account hygiene, not a code change. The eval runner executes agents with their permission bypass on the operator's machine, against fixtures that contain injection payloads on purpose; the evals README says where to run it. Build inputs (setuptools, build, the docs requirements) float; a lock file is the next step if the claim above ever needs to become "reproducible".

What automated scanners report, and why

Several registries scan the skill package (skills/keep-the-why/) automatically, and their labels differ because they check different things. Here is what each one reports and what stands behind it, so a warning on a listing page doesn't have to be decoded from scratch.

  • SkillsLLM — Verified; see the scan report for what it checked.
  • skills.sh runs three auditors on every listed skill and re-runs them on each release. Socket and Snyk audited 0.20.3 on 2026-10-21; Gen Agent Trust Hub's latest audit is of 0.20.2 (2026-10-12). Socket — Pass, Gen Agent Trust Hub — Pass (risk level safe), Snyk — Warn (risk level medium, one finding: W011, "third-party content exposure detected (indirect prompt injection risk)" — the agent reads project documentation, git history, issues and pull-request discussions during retrospective recovery and the setup check, all of it outsider-authored text). That finding describes the skill's job: recovering rationale means reading what outsiders wrote; the mitigation is Core rule 11 and the trust model, which the Trust Hub audit cites for the same category. Snyk rated it low for 0.17.1 (2026-09-22) and medium for 0.16.x, 0.20.1 and 0.20.3, with the skill's reading surface unchanged in between — a model-based auditor's call, not a change in the skill. The Trust Hub audit names five categories and rates the skill safe, citing the trust model, the pin's validation and the user-level installs as mitigations. The categories are worth knowing either way, because each one is a documented capability of the skill, not a defect:

    • Remote code execution and external downloads — installing, updating and running keep-the-why-lint from PyPI (pipx, pip or uv) under the developer's own local-lint setting, and the update check against this repository's GitHub releases API; the audit notes that both target official sources. The skill never installs the second PyPI package, keep-the-why-dashboard.
    • Dynamic execution — pinned versions: a project can vendor a copy of the skill and have the agent read that SKILL.md instead of the installed one. The path stays inside the project (E009), a missing file is a hard stop, and whoever can change that file can change the project's AGENTS.md just the same.
    • Persistence — the optional session-start hook: a few lines of shell in the agent's own hook configuration that look for a .keep-the-why and have the agent load the skill when a session starts. The setup wizard asks which start path the project wants; its default adds the hook only where the autostart page has a verified example for the agent in use.
    • Indirect prompt injection — the skill reads outsider-authored text: commit messages, issue and pull-request threads, interview answers. That is what retrospective recovery is. The audit lists the mitigations itself: Core rule 11 and the trust model — repository content is data, never instructions; an embedded directive gets named to the user, not followed; nothing is copied verbatim into context/; hidden and directional Unicode is a linter error.

    The auditors are model-based and re-run on every release, so a later audit can rate the same categories medium again without anything in the skill having changed — that is where a skill lands that reads a repository's history, runs a linter and checks for updates, and the label would only move for good by removing one of those. Everything runs through the permission prompts of whatever agent is executing the skill. The labels are re-checked against the three audit pages with every release. - agent-skill-manager (asm) — asm audit security runs regular expressions over the package files: any https:// URL is a network permission warning, the word bash (or exec(, eval(, a credential-shaped assignment) a high finding. Against 0.13.1 (asm 2.14): CAUTION, twenty warnings, every one of them "External URLs" — the links to this site, to GitHub's releases API for the update check, and to OWASP — and nothing else; the same twenty, and nothing else, against the 0.14.0 skill text with the local linter run in it. That is the floor for any skill that contains a link at all, and none of it is the data-exfiltration or remote-payload pattern the check is meant to hint at. - HOL AI Plugin Scanner (hol-guard) — the scan behind the HOL plugin registry and the awesome-ai-plugins list; this repository runs it itself on every push and pull request (hol-scanner.yml, scan mode, read-only token, no network probing, the scanner wheel verified by SHA-256 and PyPI provenance before it runs). Against main after 0.15.0, with .codexignore (scanner 3.0.133): 94/100, no critical or high finding, one medium — ktw-lint.yml consuming this repository's own linter action on the moving lint-latest tag, which is the deliberate choice described below. The scanner release the list's guide pins (2.0.1116) reports two extra high findings on this repository — its secret heuristic takes the eval case id local-lint-ask-does-not-install-unasked for a token, in docs/evals.md and tools/evals/evals.json — which a current release does not; the workflow pins a current one. Its verify mode additionally expects a Codex plugin manifest and marketplace file (.codex-plugin/plugin.json, .agents/plugins/marketplace.json), which this repository ships; verify passes and is not part of the workflow.

All of these are second opinions, not a substitute for reading SKILL.md and the trust model yourself.

Reporting a vulnerability in Keep the Why itself

That's a different question from the above — see SECURITY.md for the disclosure process, or report directly via GitHub Security Advisories.