Skip to content

feat(NO-TASK): Require trust for repository hooks and contain path arguments - #69

Merged
aaronware merged 2 commits into
mainfrom
feat/hook-trust-and-path-containment
Aug 30, 2026
Merged

feat(NO-TASK): Require trust for repository hooks and contain path arguments#69
aaronware merged 2 commits into
mainfrom
feat/hook-trust-and-path-containment

Conversation

@aaronware

Copy link
Copy Markdown
Contributor

Security review of the wt surface, closing five findings. Each was reproduced with a working proof-of-concept before the fix and re-verified against the built CLI after it.

Full review: https://claude.ai/code/artifact/4510690e-32f4-4560-a752-e14f1f7cf389

Why these matter more than usual

This CLI is designed to be pre-approved in an agent allowlist — package.json calls it "deterministic verbs agents can call without approval prompts." A rule like Bash(linchpin wt:*) matches the verb by prefix and cannot see the argument or what the repository has committed. So the security boundary is not the verb; it is what the verb does with untrusted input.

src/core/exec.ts was already right — argv arrays, no shell: true, branch names inert as data. Every finding is a layer up: what a repository could direct the tool to do once someone cloned it.

Findings

Finding Severity
F1 Committed hooks made git clone equivalent to running code High
F2 wt invoke escaped the hooks directory and sourced any file High
F3 A cloned repo chose what --force recursively deleted High
F4 wt copy / wt link shared the same unbounded join Medium
F5 Registry answers trusted further than needed Low

F1.linchpin/hooks/* are committed, so they arrive with a clone, unlike .git/hooks which git deliberately refuses to transfer for exactly this reason. Sourced rather than executed, a hook needs no execute bit and no shebang, so nothing in a diff marks it as code that will run. Verified: clone + one ordinary wt switch executed a committed hook. Now gated behind content-hashed trust, direnv-style.

F3 — the recursive delete target comes from the committed .linchpin.json. Verified: a cloned repo destroyed a directory of real data, no backup taken. Now requires a WordPress-slot target and confirmation.

⚠️ Behaviour change for automation

Not marked breaking, so this needs calling out explicitly:

  • Scripts or agent allowlists calling wt switch --force must add --yes or they will now fail rather than delete.
  • CI relying on hooks needs them trusted first (linchpin wt trust --all, or seed LINCHPIN_TRUST_FILE).
  • An internal http registry now needs LINCHPIN_REGISTRY_ALLOW_INSECURE=1.

docs/hooks.md documents the trust workflow. README.md still needs its hooks section updated — deliberately left alone here to avoid colliding with #shell-update-notice.

Verification

  • 143/143 tests pass, typecheck clean. 30 new tests across paths, trust and update-registry, dual-mode so the legacy CJS and ported TS twins cannot drift.
  • Each PoC re-run against the build. The clone attack now fails three times over, independently: hook blocked as untrusted, --force refuses without a TTY, and the slot guard refuses even when --yes is supplied.
  • Eight pre-existing tests changed expectations — hook tests now trust their hooks first, and the clobber test moved to a wp-content-shaped path. Worth confirming in review that those are the right new expectations rather than tests bent to fit.

Notes for reviewers

Three subtleties that were real traps:

  • resolveContained uses path.resolve, not path.joinjoin('/repo', '/etc/passwd') silently yields /repo/etc/passwd, rewriting the caller's path instead of refusing it.
  • isContainedAfterLinks realpaths both sides. Git tracks symlinks, so a repo can commit a hook that is one; and resolving only the candidate would compare /private/tmp/… against /tmp/… and reject every legitimate path on macOS.
  • Trust keys on the hook's realpath for the same reason — keying on the raw string filed approval under one name and looked it up under another.

🤖 Generated with Claude Code

aaronware and others added 2 commits August 30, 2026 00:16
…guments

Closes four ways a cloned repository could execute code or destroy data through
the wt surface. This matters more than usual here because the CLI is designed to
be pre-approved in an agent allowlist: a rule like Bash(linchpin wt:*) matches
the verb by prefix and cannot see the argument or the repository contents.

Hook trust. .linchpin/hooks/* are committed files, so they arrive with a clone --
unlike .git/hooks, which git deliberately refuses to transfer for exactly this
reason. Sourced rather than executed, a hook needs no execute bit and no shebang,
so nothing in a diff marks it as code that will run. Hooks now do nothing until
the machine approves them, keyed to a sha256 of the contents, so editing a
trusted hook withdraws its trust. Adds `linchpin wt trust` to list, grant, --all
and --revoke. Follows direnv and mise in blocking with instructions rather than
prompting. A blocked hook does not fail the operation, and a hook that runs
prints its path.

Path containment. findHookFile joined an argv value straight onto the hooks
directory and the result was sourced as bash, so `wt invoke ../../../payload`
ran any file on the machine; wt copy and wt link shared the pattern feeding
cpSync and symlinkSync. All three now resolve through a containment helper.

Destructive switch. The target of the recursive delete behind `wt switch --force`
comes from the committed .linchpin.json, so a repository chose what got removed.
The target must now sit inside a WordPress content directory, and --force asks
before destroying anything, refusing rather than assuming consent when there is
no TTY. --yes is the explicit scriptable answer.

Behaviour change for automation: scripts calling `wt switch --force` need --yes,
and CI relying on hooks needs them trusted first. See docs/hooks.md.

resolveContained uses path.resolve rather than path.join, because join folds an
absolute segment into the root instead of refusing it. isContainedAfterLinks
resolves both sides, since git tracks symlinks and a candidate-only realpath
would compare /private/tmp against /tmp and reject every legitimate path on
macOS. Trust keys on the hook realpath for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nses

LINCHPIN_REGISTRY and npm_config_registry were accepted with no scheme check, so
a plaintext registry -- which anyone on the path can answer -- was honoured. They
are environment values, exactly as trustworthy as whatever set them. http is now
refused unless the host is loopback, so a local Verdaccio mirror still works, or
LINCHPIN_REGISTRY_ALLOW_INSECURE is set.

The latest dist-tag and the response status text both reach a terminal, and
neither was stripped of control characters, so a hostile or intercepted registry
could rewrite the output around an update notice with ANSI escapes. Both are
sanitized at the boundary rather than at each print site, because latest is
cached to disk and read back by later runs -- cleaning it on arrival is what
stops a hostile answer outliving the request that fetched it.

The install command was never at risk: it is a hardcoded name@latest with no
argv injection. The child npm does inherit the environment, so a poisoned
npm_config_registry still steers the real install; that is npm's own behaviour
and is out of scope here.

Also fixes an unparseable registry value, including the empty string, producing
a relative URL and a confusing fetch failure. It now falls back to the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread test-utils/cli-fixture.js
function trustHooks(basePath) {
const hooksDir = path.join(basePath, '.linchpin', 'hooks');

let store = { hooks: {} };
Comment thread legacy/commands/wt.js
const { findHookFile, runHook } = require('../lib/hooks');
const { findHookFile, runHook: runHookRaw } = require('../lib/hooks');
const {
describeUntrustedHook,
Comment thread legacy/commands/wt.js
const { findHookFile, runHook: runHookRaw } = require('../lib/hooks');
const {
describeUntrustedHook,
hashHookFile,
Comment thread legacy/commands/wt.js
describeUntrustedHook,
hashHookFile,
isHookTrusted,
readTrustStore,
@aaronware
aaronware merged commit 759e0e6 into main Aug 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant