@mutmutco/cursor-plugin 4.2.7 → 4.3.1

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.
Files changed (35) hide show
  1. package/.cursor-plugin/plugin.json +2 -3
  2. package/package.json +1 -1
  3. package/scripts/edit-tool-paths.mjs +4 -4
  4. package/skills/bootstrap/SKILL.md +2 -2
  5. package/skills/bootstrap/seeds/README.template.md +2 -2
  6. package/skills/bootstrap/seeds/gate.template.yml +5 -5
  7. package/skills/bootstrap/seeds/manifest.json +1 -0
  8. package/skills/bootstrap/seeds/test-policy.template.json +4 -0
  9. package/skills/hotfix/SKILL.md +1 -1
  10. package/skills/rcand/SKILL.md +1 -1
  11. package/skills/release/SKILL.md +38 -9
  12. package/skills/secrets/SKILL.md +1 -1
  13. package/skills/stage/SKILL.md +1 -1
  14. package/bin/mmi-hook +0 -2
  15. package/bin/mmi-hook-console.cmd +0 -16
  16. package/bin/mmi-hook.exe +0 -0
  17. package/hooks/cursor-hooks.json +0 -26
  18. package/scripts/command-ladder-core.mjs +0 -339
  19. package/scripts/command-ladder-gate.mjs +0 -126
  20. package/scripts/deny-gate-crash.mjs +0 -179
  21. package/scripts/env-write-lint.mjs +0 -146
  22. package/scripts/hook-io.mjs +0 -22
  23. package/scripts/hook-policy.mjs +0 -78
  24. package/scripts/hook-run.mjs +0 -434
  25. package/scripts/hook-trace.mjs +0 -151
  26. package/scripts/pretooluse-shell-gates.mjs +0 -720
  27. package/scripts/secret-echo-lint.mjs +0 -177
  28. package/scripts/test-command-policy-core.mjs +0 -294
  29. package/scripts/throttle-core.mjs +0 -332
  30. package/scripts/vault-edit-gate.mjs +0 -94
  31. package/skills/browser-automation/SKILL.md +0 -122
  32. package/skills/mmi/SKILL.md +0 -544
  33. package/skills/mmi-doctor/SKILL.md +0 -66
  34. package/skills/mmi-resume/SKILL.md +0 -123
  35. package/skills/onboard/SKILL.md +0 -72
@@ -1,146 +0,0 @@
1
- // No-env floor, shell surface (#2663): deny Bash/PowerShell/shell commands that CREATE a real .env file.
2
- // #3563 adds the patch form: Codex accepts `apply_patch <<'EOF' … EOF` through its SHELL tools, which the
3
- // Edit|Write|apply_patch matcher never sees, so patch bodies are read here too.
4
- // The Edit/Write vault-edit-gate already blocks hand-writing a .env through the file tools; this closes the
5
- // shell bypass — `cp .env.example .env`, `echo ... > .env`, `tee .env`, `Out-File .env`, etc. Reads
6
- // (`cat .env`, `source .env`, `< .env`), deletes (`rm .env`), and the allowed template names
7
- // (`.env.example`, `*.template`) are NOT blocked. Pure detection — exported for tests, no IO here.
8
-
9
- import { applyPatchWriteTargets, invokesApplyPatch } from './edit-tool-paths.mjs';
10
-
11
- const DENY_REASON =
12
- 'No .env — the org is env-free. This command creates a .env file; don\'t. Deliver config vault-native '
13
- + '(`mmi-cli stage` injects secrets into the process env; `mmi-cli vault secrets` manages names). If something '
14
- + 'genuinely cannot proceed without a .env, STOP and consult the board (file an issue) — do not create it.';
15
-
16
- /** True for a token that names a REAL .env file (`.env` or `.env.<suffix>`), excluding the allowed template
17
- * names (`.env.example`, anything ending `.example`, anything containing `.template`). Strips surrounding
18
- * quotes and any directory prefix. */
19
- export function isRealEnvToken(token) {
20
- if (!token || typeof token !== 'string') return false;
21
- const unquoted = token.replace(/^['"]/, '').replace(/['"]$/, '');
22
- const base = unquoted.replace(/\\/g, '/').split('/').pop() ?? '';
23
- // Win32 strips trailing dots and spaces from the final path component, so `.env.` and `.env ` both
24
- // CREATE `.env` — through any writer that goes via Win32 path normalisation, which includes Rust's
25
- // std::fs, the writer behind Codex's apply_patch. Node/libuv uses verbatim paths and is the exception,
26
- // which is why this never showed up from a Node probe (cross-vendor check, kimi-k3).
27
- const b = base.replace(/[. ]+$/, '').toLowerCase() || base.toLowerCase();
28
- if (!/^\.env(\.[^/]+)?$/.test(b)) return false;
29
- if (b === '.env.example' || b.endsWith('.example') || b.includes('.template')) return false;
30
- return true;
31
- }
32
-
33
- // A path-ish token: no shell metacharacters. Used to pull redirect/cmdlet targets out of a command.
34
- const PATH_TOKEN = "(['\"]?[^\\s'\"|;&<>()]+['\"]?)";
35
-
36
- // Redirection to a target: >, >>, 1>, 2>, &>, >| — the classic `echo x > .env` / `cat > .env` create.
37
- const REDIRECT_RE = new RegExp(`(?:\\d?>>?\\|?|&>)\\s*${PATH_TOKEN}`, 'g');
38
- // tee / Tee-Object (with any flags) writing a file.
39
- const TEE_RE = new RegExp(`\\b(?:tee|Tee-Object)\\b(?:\\s+-{1,2}\\S+)*\\s+${PATH_TOKEN}`, 'gi');
40
- // PowerShell content writers targeting a file (positional or -Path/-FilePath/-LiteralPath).
41
- const PS_WRITE_RE = new RegExp(
42
- `\\b(?:Out-File|Set-Content|Add-Content|New-Item)\\b[^|;&]*?(?:-(?:Path|FilePath|LiteralPath)\\s+)?${PATH_TOKEN}`,
43
- 'gi',
44
- );
45
- // Copy/move/link verbs — the destination is (conventionally) the LAST path token; block when it is a real .env.
46
- // `copy`/`move` are the cmd.exe spellings AND the default PowerShell aliases for Copy-Item/Move-Item, so
47
- // `copy .env.example .env` reached disk past every analyzer (pre-existing since #2663; found by the #3563
48
- // cross-vendor check, openai-sol). `xcopy`/`robocopy` are the other two Windows spellings.
49
-
50
- // #5820: segment separators include NEWLINES. Without them a multi-line script — a heredoc body, an
51
- // SSH `bash -s` payload — collapses into ONE segment, and the copy/git/touch scans below then pair a
52
- // verb on one line with "the last path token in the segment" from an unrelated line further down:
53
- // `cp a b` on line 1 plus a later line ending in an env-shaped token was denied as env_write_copy,
54
- // neither line a write (found applying the documented jerv-central procedure, runbooks/box-ops.md).
55
- // Adding \r\n strictly TIGHTENS the scans — every real create form still matches on its own line.
56
- const SEGMENT_RE = /(?:\|\||&&|[;&|\r\n])/;
57
-
58
- const COPY_VERBS = /^(?:cp|mv|install|ln|rsync|copy|move|xcopy|robocopy|Copy-Item|Move-Item)$/i;
59
-
60
- /** Inspect one command for a real-.env CREATE. Returns { block, reason, reasonId } or null (clean). */
61
- export function analyze({ toolName, command } = {}) {
62
- void toolName;
63
- if (!command || typeof command !== 'string') return null;
64
-
65
- // #3563: a patch can arrive through the SHELL, not just the `apply_patch` tool — Codex accepts
66
- // `apply_patch <<'EOF' … EOF` and `bash -lc "apply_patch <<'EOF' …"` via its shell tools. Those calls
67
- // carry `tool_name: "shell"`, so the Edit|Write|apply_patch matcher never fires the vault gate, and the
68
- // shell lints below see no redirect, tee or copy to catch. Same matcher-vs-payload gap as the tool
69
- // form, one door over (cross-vendor check, kimi-k3), so the shell gate reads patch bodies too.
70
- //
71
- // Two precision rules, both from a false deny: the command must actually INVOKE `apply_patch` (patch
72
- // text inside a `cat` heredoc writes nothing), and only WRITE targets count (a `*** Delete File: .env`
73
- // removes a .env, which this floor has never blocked).
74
- if (invokesApplyPatch(command)) {
75
- for (const target of applyPatchWriteTargets(command)) {
76
- if (isRealEnvToken(target)) return deny('env_write_apply_patch');
77
- }
78
- }
79
-
80
- for (const m of command.matchAll(REDIRECT_RE)) {
81
- if (isRealEnvToken(m[1])) return deny('env_write_redirect');
82
- }
83
- for (const m of command.matchAll(TEE_RE)) {
84
- if (isRealEnvToken(m[1])) return deny('env_write_tee');
85
- }
86
- for (const m of command.matchAll(PS_WRITE_RE)) {
87
- if (isRealEnvToken(m[1])) return deny('env_write_ps');
88
- }
89
-
90
- // Copy/move/link: only the DESTINATION (last path token in the segment) creating a real .env is a write —
91
- // `cat .env` (read) stays allowed. A COPY of a .env is itself a .env-shaped secret file, so
92
- // `cp .env .env.bak` is blocked too; an earlier version of this comment promised otherwise and the code
93
- // never did (cross-vendor check, openai-sol). Under "no .env ever" the code is right.
94
- //
95
- // The verb is found ANYWHERE in the segment, not only at token 0: `cmd /c copy …`, `sudo cp …` and
96
- // `bash -lc "cp …"` all put a wrapper first. And the destination is not always last — PowerShell takes
97
- // `-Destination`, and `cp <file> <dir>/` names the directory while the basename comes from the source
98
- // (all four ALLOWED before this; cross-vendor check, openai-sol).
99
- for (const raw of command.split(SEGMENT_RE)) {
100
- const seg = raw.trim();
101
- if (!seg) continue;
102
- const tokens = seg.split(/\s+/).filter(Boolean);
103
- const verbAt = tokens.findIndex((t) => COPY_VERBS.test(t.replace(/\\/g, '/').split('/').pop() ?? ''));
104
- if (verbAt === -1) continue;
105
- const after = tokens.slice(verbAt + 1);
106
- // Named destination wins when present (`Copy-Item -Destination .env -Path x` puts it first).
107
- const namedAt = after.findIndex((t) => /^-(?:Destination|dest)$/i.test(t));
108
- if (namedAt !== -1 && isRealEnvToken(after[namedAt + 1])) return deny('env_write_copy');
109
- const paths = after.filter((t) => !t.startsWith('-'));
110
- if (isRealEnvToken(paths[paths.length - 1])) return deny('env_write_copy');
111
- // `cp .env dir/` lands `dir/.env`: a directory destination takes the SOURCE's basename.
112
- const dest = paths[paths.length - 1];
113
- if (paths.length > 1 && dest && /[\\/]$/.test(dest) && paths.slice(0, -1).some((p) => isRealEnvToken(p))) {
114
- return deny('env_write_copy');
115
- }
116
- }
117
-
118
- // `git checkout <ref> -- <path>` / `git restore --source <ref> <path>` materialise a file from history.
119
- for (const raw of command.split(SEGMENT_RE)) {
120
- const tokens = raw.trim().split(/\s+/).filter(Boolean);
121
- const gitAt = tokens.findIndex((t) => (t.replace(/\\/g, '/').split('/').pop() ?? '') === 'git');
122
- if (gitAt === -1 || !/^(?:checkout|restore)$/i.test(tokens[gitAt + 1] ?? '')) continue;
123
- if (tokens.slice(gitAt + 2).some((t) => isRealEnvToken(t))) return deny('env_write_git_restore');
124
- }
125
-
126
- // touch: any non-flag arg that is a real .env token creates the file.
127
- for (const raw of command.split(SEGMENT_RE)) {
128
- const seg = raw.trim();
129
- if (!seg) continue;
130
- const tokens = seg.split(/\s+/).filter(Boolean);
131
- if (!tokens.length || tokens[0].toLowerCase() !== 'touch') continue;
132
- const paths = tokens.slice(1).filter((t) => !t.startsWith('-'));
133
- if (paths.some((p) => isRealEnvToken(p))) return deny('env_write_touch');
134
- }
135
-
136
- // dd of=<path>: block when the of= target is a real .env token.
137
- for (const m of command.matchAll(/\bdd\b[^|;&]*?\bof=(['"]?[^\s'"|;&]+['"]?)/gi)) {
138
- if (isRealEnvToken(m[1])) return deny('env_write_dd');
139
- }
140
-
141
- return null;
142
- }
143
-
144
- function deny(reasonId) {
145
- return { block: true, reason: DENY_REASON, reasonId };
146
- }
@@ -1,22 +0,0 @@
1
- // Shared stdin reader for Claude Code hook scripts (#1668).
2
- // Each hook script receives a JSON payload on stdin. This helper reads it and
3
- // returns the parsed object. Throws on any read or parse error so callers can
4
- // catch and exit 0 (hooks must never crash a turn).
5
- import { createInterface } from 'node:readline';
6
-
7
- /**
8
- * Read all stdin lines and JSON-parse them.
9
- * #4118: the shared runner now runs a gate IN-PROCESS and has already drained stdin, so it passes the
10
- * buffered payload in. Parsing it here keeps ONE contract — same throw on unreadable input, so every
11
- * gate's fail-open/fail-closed branch is reached identically whether it was spawned or imported.
12
- * @param {Buffer|string} [buffered] Payload already read by the caller; stdin is read when absent.
13
- * @returns {Promise<unknown>} Parsed JSON payload.
14
- * @throws {Error} On readline or JSON parse failure.
15
- */
16
- export async function readHookInput(buffered) {
17
- if (buffered !== undefined) return JSON.parse(Buffer.isBuffer(buffered) ? buffered.toString('utf8') : String(buffered));
18
- const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
19
- const lines = [];
20
- for await (const line of rl) lines.push(line);
21
- return JSON.parse(lines.join('\n'));
22
- }
@@ -1,78 +0,0 @@
1
- // Canonical MMI hook policy. Host adapters select a named gate; they do not choose scripts,
2
- // failure posture, timeouts, or response semantics themselves.
3
-
4
- export const HOOK_POLICY_VERSION = 2;
5
-
6
- export const HOOK_GATES = Object.freeze({
7
- 'command-ladder': Object.freeze({
8
- event: 'PreToolUse',
9
- script: 'pretooluse-shell-gates.mjs',
10
- failure: 'closed',
11
- fallbackGate: 'command-ladder',
12
- timeoutMs: 10_000,
13
- }),
14
- 'vault-edit': Object.freeze({
15
- event: 'PreToolUse',
16
- script: 'vault-edit-gate.mjs',
17
- failure: 'closed',
18
- fallbackGate: 'vault-edit',
19
- timeoutMs: 5_000,
20
- }),
21
- });
22
-
23
- export const HOOK_SURFACES = Object.freeze({
24
- claude: Object.freeze({
25
- lifecycle: 'active',
26
- rootEnv: Object.freeze(['CLAUDE_PLUGIN_ROOT', 'PLUGIN_ROOT']),
27
- postToolOutput: 'unsupported',
28
- finalOutput: 'unsupported',
29
- }),
30
- codex: Object.freeze({
31
- lifecycle: 'active',
32
- rootEnv: Object.freeze(['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT']),
33
- postToolOutput: 'unsupported',
34
- finalOutput: 'unsupported',
35
- }),
36
- kimi: Object.freeze({
37
- lifecycle: 'active',
38
- rootEnv: Object.freeze(['KIMI_PLUGIN_ROOT', 'PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT']),
39
- postToolOutput: 'unsupported',
40
- finalOutput: 'unsupported',
41
- }),
42
- cursor: Object.freeze({
43
- lifecycle: 'active',
44
- rootEnv: Object.freeze([]),
45
- postToolOutput: 'unsupported',
46
- finalOutput: 'unsupported',
47
- }),
48
- kilo: Object.freeze({
49
- lifecycle: 'active',
50
- rootEnv: Object.freeze(['KILO_PLUGIN_ROOT', 'PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT']),
51
- postToolOutput: 'unsupported',
52
- finalOutput: 'unsupported',
53
- }),
54
- jervcode: Object.freeze({
55
- lifecycle: 'active',
56
- rootEnv: Object.freeze(['PI_PLUGIN_ROOT']),
57
- postToolOutput: 'unsupported',
58
- finalOutput: 'unsupported',
59
- }),
60
- hermes: Object.freeze({
61
- lifecycle: 'active',
62
- rootEnv: Object.freeze(['HERMES_PLUGIN_ROOT']),
63
- postToolOutput: 'unsupported',
64
- finalOutput: 'unsupported',
65
- }),
66
- });
67
-
68
- export function hookGate(id) {
69
- const gate = HOOK_GATES[id];
70
- if (!gate) throw new Error(`unknown MMI hook gate: ${id || '(empty)'}`);
71
- return gate;
72
- }
73
-
74
- export function hookSurface(token) {
75
- const surface = HOOK_SURFACES[token];
76
- if (!surface) throw new Error(`unknown MMI hook surface: ${token || '(empty)'}`);
77
- return surface;
78
- }