Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .claude/agents/security-reviewer.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions .claude/commands/setup-security-tools.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# acorn-wasm — shared parser for fleet hooks
# acorn — shared wasm parser for fleet hooks

Vendored from
[`@ultrathink/acorn-monorepo`](https://github.com/SocketDev/ultrathink/tree/main/packages/acorn)'s
Expand All @@ -13,7 +13,7 @@ The three vendored files come straight from the ultrathink prod build:

- `acorn.wasm` — compiled Rust acorn parser, ~3.3 MB.
- `acorn-bindgen.cjs` — wasm-bindgen JS glue.
- `acorn-wasm-sync.mts` — sync ESM loader (no top-level await,
- `acorn-sync.mts` — sync ESM loader (no top-level await,
`WebAssembly.Instance` constructed at module import).

The artifact is rebuilt in ultrathink with `pnpm run
Expand All @@ -39,12 +39,12 @@ Last refreshed: 2026-05-20 (ultrathink build dated 2026-05-20).

## Public surface

`template/.claude/hooks/_shared/acorn/index.mts` is the canonical
`template/.claude/hooks/fleet/_shared/acorn/index.mts` is the canonical
import path for fleet hooks. It re-exports a narrow `tryParse` /
`walkSimple` / `findBareCallsTo` surface — see the module's JSDoc for
the parse-failure tolerance + visitor patterns hook authors rely on.

Don't import `acorn-wasm-sync.mts` directly from hooks; the `index.mts`
Don't import `acorn-sync.mts` directly from hooks; the `index.mts`
wrapper provides the failure-handling + visitor adapters every hook
needs.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/**
* @file Shared acorn-wasm wrapper for fleet hooks. Vendored from
* socket-lib/vendor/acorn-wasm pending the `@ultrathink/acorn` npm publish;
* once that lands, fleet hooks switch to the published package and this
* directory can be retired. Surface kept narrow: `parse(source, opts)` for
* raw AST + `simple(source, visitors, opts)` for visitor-based walks.
* Higher-level shape detectors (`findCallsTo`, `findBareCallsTo`) cover the
* common "lint a specific identifier call" pattern that hooks need.
* socket-lib/vendor/acorn pending the `@ultrathink/acorn` npm publish; once
* that lands, fleet hooks switch to the published package and this directory
* can be retired. Surface kept narrow: `parse(source, opts)` for raw AST +
* `simple(source, visitors, opts)` for visitor-based walks. Higher-level
* shape detectors (`findCallsTo`, `findBareCallsTo`) cover the common "lint a
* specific identifier call" pattern that hooks need.
*/

import { parse as wasmParse, simple as wasmSimple } from './acorn-wasm-sync.mts'
import { parse as wasmParse, simple as wasmSimple } from './acorn-sync.mts'

export interface AcornNode {
type: string
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
/**
* @file Single source of truth for fleet-repo membership, shared by the
* hooks that need to know "is this one of ours?":
* @file Single source of truth for fleet-repo membership, shared by the hooks
* that need to know "is this one of ours?":
*
* - `cross-repo-guard` — blocks `../<fleet-repo>/…` sibling-path imports.
* - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the
* fleet (a non-fleet repo never has the fleet hook chain installed, so
* the guard has to live agent-side and know the roster itself).
*
* This is the BROAD membership set, intentionally wider than the cascade
* roster in `cascading-fleet/lib/fleet-repos.json` (which lists only
* template-cascade targets and omits e.g. `ultrathink`). Membership here
* answers "may fleet tooling act on this repo at all", not "does the
* wheelhouse cascade into it". Keep the two distinct: a repo can be a
* fleet member (pushable, importable) without being a cascade target.
* - `no-non-fleet-push-guard` — blocks `git push` to a repo not in the fleet (a
* non-fleet repo never has the fleet hook chain installed, so the guard has
* to live agent-side and know the roster itself). This is the BROAD
* membership set, intentionally wider than the cascade roster in
* `cascading-fleet/lib/fleet-repos.json` (which lists only template-cascade
* targets and omits e.g. `ultrathink`). Membership here answers "may fleet
* tooling act on this repo at all", not "does the wheelhouse cascade into
* it". Keep the two distinct: a repo can be a fleet member (pushable,
* importable) without being a cascade target.
*/

// All under the SocketDev org. Names match the GitHub repo slug
Expand Down Expand Up @@ -40,24 +39,24 @@ const FLEET_REPO_SET: ReadonlySet<string> = new Set(FLEET_REPO_NAMES)

/**
* True when `slug` (a bare repo name like `socket-cli`) is a fleet member.
* Case-insensitive — GitHub slugs are case-insensitive and remotes can be
* typed in any case.
* Case-insensitive — GitHub slugs are case-insensitive and remotes can be typed
* in any case.
*/
export function isFleetRepo(slug: string): boolean {
return FLEET_REPO_SET.has(slug.toLowerCase())
}

/**
* Extract the bare repo slug from a git remote URL, or `undefined` when the
* URL isn't a recognizable GitHub remote. Handles the three forms git emits:
* Extract the bare repo slug from a git remote URL, or `undefined` when the URL
* isn't a recognizable GitHub remote. Handles the three forms git emits:
*
* git@github.com:SocketDev/socket-cli.git (SSH scp-like)
* ssh://git@github.com/SocketDev/socket-cli.git (SSH URL)
* https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git)
* Git@github.com:SocketDev/socket-cli.git (SSH scp-like)
* ssh://git@github.com/SocketDev/socket-cli.git (SSH URL)
* https://github.com/SocketDev/socket-cli.git (HTTPS, optional .git)
*
* Returns the slug only (`socket-cli`), lowercased. The owner is dropped on
* purpose: membership is keyed on the repo name, and a fork under a
* different owner is still not a fleet push target.
* purpose: membership is keyed on the repo name, and a fork under a different
* owner is still not a fleet push target.
*/
export function slugFromRemoteUrl(url: string): string | undefined {
const trimmed = url.trim()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
/**
* @file Shared heuristic for "which dirty paths in this checkout were authored
* by ANOTHER agent, not this session". Two responsibilities the parallel-agent
* hooks (and overeager-staging-guard) share:
* by ANOTHER agent, not this session". Two responsibilities the
* parallel-agent hooks (and overeager-staging-guard) share:
*
* 1. `readTouchedPaths(transcriptPath)` — the set of absolute paths THIS
* session modified: Edit / Write `file_path` targets plus `git add|mv|rm
* <path>` arguments parsed out of Bash commands. Lifted here from
* 1. `readTouchedPaths(transcriptPath)` — the set of absolute paths THIS session
* modified: Edit / Write `file_path` targets plus `git add|mv|rm <path>`
* arguments parsed out of Bash commands. Lifted here from
* overeager-staging-guard so the three consumers share one implementation
* instead of drifting copies.
* 2. `listForeignDirtyPaths(repoDir, touched, opts)` — dirty paths
* (`git status --porcelain`) that this session did NOT touch and whose
* mtime is recent (so stale pre-session dirt doesn't false-fire). These are
* the likely fingerprints of a concurrent Claude session sharing the
* `.git/` — the failure mode where `git add -A` / `git stash` / `git
* reset --hard` would sweep up or destroy another agent's work.
*
* Fail-open contract (matches the rest of `_shared/`): every helper returns a
* safe default on any parse / I/O error rather than throwing. A hook that
* crashes wedges every Claude Code call; one that returns "nothing foreign"
* simply falls through to the hook's default decision.
* 2. `listForeignDirtyPaths(repoDir, touched, opts)` — dirty paths (`git status
* --porcelain`) that this session did NOT touch and whose mtime is recent
* (so stale pre-session dirt doesn't false-fire). These are the likely
* fingerprints of a concurrent Claude session sharing the `.git/` — the
* failure mode where `git add -A` / `git stash` / `git reset --hard` would
* sweep up or destroy another agent's work. Fail-open contract (matches
* the rest of `_shared/`): every helper returns a safe default on any
* parse / I/O error rather than throwing. A hook that crashes wedges every
* Claude Code call; one that returns "nothing foreign" simply falls
* through to the hook's default decision.
*/

import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
Expand All @@ -45,9 +44,13 @@ const UNTRACKED_BY_DEFAULT_PREFIXES = [
const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000

export interface ForeignPathsOptions {
/** Max age (ms) of a dirty path's mtime to count as foreign. */
/**
* Max age (ms) of a dirty path's mtime to count as foreign.
*/
readonly maxAgeMs?: number | undefined
/** Injectable clock for tests. Defaults to `Date.now()`. */
/**
* Injectable clock for tests. Defaults to `Date.now()`.
*/
readonly now?: number | undefined
}

Expand All @@ -62,10 +65,10 @@ export function isUntrackedByDefault(p: string): boolean {

/**
* Parse `git add|mv|rm <path>` arguments out of a Bash command line and add the
* resolved absolute paths to `touched`. Broad forms (`git add .` / `-A`) are NOT
* surgical adds and are skipped — they don't establish authorship of a specific
* file. Tolerates leading `NAME=val` env assignments and `&&` / `;` / `|`
* chains.
* resolved absolute paths to `touched`. Broad forms (`git add .` / `-A`) are
* NOT surgical adds and are skipped — they don't establish authorship of a
* specific file. Tolerates leading `NAME=val` env assignments and `&&` / `;` /
* `|` chains.
*/
export function addTouchedFromBash(
command: string,
Expand Down Expand Up @@ -173,7 +176,7 @@ export interface DirtyEntry {

/**
* Parse `git status --porcelain` output, dropping untracked-by-default trees.
* Rename entries (`R old -> new`) resolve to the new path.
* Rename entries (`R old -> new`) resolve to the new path.
*/
export function parsePorcelain(out: string): DirtyEntry[] {
const entries: DirtyEntry[] = []
Expand All @@ -196,11 +199,11 @@ export function parsePorcelain(out: string): DirtyEntry[] {
/**
* Dirty paths this session did NOT author and that changed recently — the
* fingerprint of a concurrent agent on the same `.git/`. A path qualifies when:
* - it's dirty (modified / deleted / untracked, minus vendored trees), AND
* - its resolved absolute path is not in `touched`, AND
* - its on-disk mtime is within `maxAgeMs` of `now`.
* Deleted paths (no mtime) are included only if their status is `D`/`R` — a
* delete by another agent is still foreign. Returns repo-relative paths.
* - it's dirty (modified / deleted / untracked, minus vendored trees), AND -
* its resolved absolute path is not in `touched`, AND - its on-disk mtime is
* within `maxAgeMs` of `now`. Deleted paths (no mtime) are included only if
* their status is `D`/`R` — a delete by another agent is still foreign. Returns
* repo-relative paths.
*/
export function listForeignDirtyPaths(
repoDir: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,12 @@ export function detectBroadGitAdd(command: string): string | undefined {
}
for (let k = 0, { length } = c.args; k < length; k += 1) {
const arg = c.args[k]!
if (arg === '--all' || arg === '-A' || arg === '--update' || arg === '-u') {
if (
arg === '--all' ||
arg === '-A' ||
arg === '--update' ||
arg === '-u'
) {
return `git add ${arg}`
}
if (arg === '.') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,31 +49,51 @@ test('parseCommands: comments dropped', () => {
})

test('findInvocation: matches plain git push', () => {
assert.ok(findInvocation('git push origin main', { binary: 'git', subcommand: 'push' }))
assert.ok(
findInvocation('git push origin main', {
binary: 'git',
subcommand: 'push',
}),
)
})

test('findInvocation: matches git -C <dir> push (subcommand after option value)', () => {
assert.ok(findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' }))
assert.ok(
findInvocation('git -C /x push', { binary: 'git', subcommand: 'push' }),
)
})

test('findInvocation: matches git -c k=v push', () => {
assert.ok(findInvocation('git -c foo=bar push', { binary: 'git', subcommand: 'push' }))
assert.ok(
findInvocation('git -c foo=bar push', {
binary: 'git',
subcommand: 'push',
}),
)
})

test('findInvocation: matches push reached via && chain', () => {
assert.ok(
findInvocation('cd /x/depot && git push', { binary: 'git', subcommand: 'push' }),
findInvocation('cd /x/depot && git push', {
binary: 'git',
subcommand: 'push',
}),
)
})

test('findInvocation: matches push in a pipe chain', () => {
assert.ok(
findInvocation('ls | grep x && git push', { binary: 'git', subcommand: 'push' }),
findInvocation('ls | grep x && git push', {
binary: 'git',
subcommand: 'push',
}),
)
})

test('findInvocation: a different subcommand does not match', () => {
assert.ok(!findInvocation('git status', { binary: 'git', subcommand: 'push' }))
assert.ok(
!findInvocation('git status', { binary: 'git', subcommand: 'push' }),
)
})

test('findInvocation: quoted "git push" in a commit message is NOT a push', () => {
Expand Down Expand Up @@ -122,7 +142,9 @@ test('commandsFor: binary-in-a-path is NOT the binary', () => {
})

test('invocationHasFlag: exact flag', () => {
assert.ok(invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w']))
assert.ok(
invocationHasFlag('codex --write prompt', 'codex', ['--write', '-w']),
)
assert.ok(invocationHasFlag('codex -w prompt', 'codex', ['--write', '-w']))
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,9 @@ export function isTokenKey(key: string): boolean {
* inspection).
*
* Kept short to minimize false positives. A "PASSWORD" mention in a
* commit-message body would otherwise trip every commit, so token-guard
* narrows matches to assignment / flag-value positions rather than any
* occurrence in arbitrary text.
* commit-message body would otherwise trip every commit, so token-guard narrows
* matches to assignment / flag-value positions rather than any occurrence in
* arbitrary text.
*/
export const SENSITIVE_NAME_FRAGMENTS: readonly string[] = [
'TOKEN',
Expand Down
46 changes: 46 additions & 0 deletions .claude/hooks/fleet/alpha-sort-reminder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# alpha-sort-reminder

PreToolUse Edit/Write hook that nudges (never blocks) when a non-code file edit
introduces a sibling block that looks unsorted. oxlint only sees JS/TS, so the
`socket/sort-*` lint rules can't reach JSON / YAML / markdown / bash. This hook
covers those surfaces per [`docs/claude.md/fleet/sorting.md`](../../../../docs/claude.md/fleet/sorting.md).

## What it flags

| Surface | Detects | Key shape |
| -------------------------------------------------- | ------------------------------------------------------------------------- | ----------- |
| JSON / JSONC (`.json`, `.jsonc`, `.oxlintrc.json`) | runs of object keys at one indent, out of ASCII order | `"name": …` |
| YAML (`.yml`, `.yaml`) | runs of mapping keys at one indent (`env:` / `with:` / matrix) | `name:` |
| Markdown (`.md`, `.markdown`) | runs of `-`/`*` bullets out of order; bullets ending in `…`/`...` | `- text` |
| Bash (`.sh`, `.bash`) | runs of all-caps `NAME=…` assignments out of order (cache-key var blocks) | `NAME=…` |

Detection is conservative: **3+** adjacent siblings at the same indent, ASCII
byte order only. False quiet beats false nag: a missed block is a review catch,
while a wrong nag trains the agent to ignore the hook.

## Trigger

Fires on `Edit` / `Write` tool calls. Reads `tool_input.file_path` +
`content`/`new_string` from the PreToolUse payload on stdin. Always exits 0; the
reminder is informational on stderr.

## Bypass

No phrase; the hook never blocks. Silence it entirely with the env var
`SOCKET_ALPHA_SORT_REMINDER_DISABLED=1`. For a genuinely order-bearing block,
just leave it unsorted and state the reason inline (the hook is advisory; review
honors the stated reason).

## Why

John-David has asked for alphanumeric sorting across every file type repeatedly
(2026-04-17 → 2026-05-29: JSON config keys, README consumer lists, workflow YAML
matrix + bash cache-key vars, "no ellipsis"). Code surfaces got lint rules; the
non-code surfaces had no enforcement. This hook closes that gap at edit time.

## Companion files

- `index.mts` — the hook; `findUnsortedBlocks(filePath, content)` is the pure,
exported detector.
- `test/index.test.mts` — node:test specs.
- `package.json` — workspace declaration so `taze` can see the hook's deps.
Loading
Loading