cursor-guard 4.2.1 → 4.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.2.1",
3
+ "version": "4.3.0",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -110,7 +110,16 @@ console.log(` MCP server: ${serverExists ? 'OK' : 'MISSING'}`);
110
110
  console.log(` MCP SDK: ${sdkExists ? 'OK' : 'MISSING — run npm install in skill dir'}`);
111
111
 
112
112
  console.log(`\n Installation complete!\n`);
113
- console.log(' ⚠ If MCP was already configured, restart Cursor (or Ctrl+Shift+P →');
113
+
114
+ // Detect git repo and recommend committing
115
+ let isGitRepoDir = false;
116
+ try { isGitRepoDir = fs.existsSync(path.join(projectDir, '.git')); } catch { /* ignore */ }
117
+ if (!isGlobal && isGitRepoDir) {
118
+ console.log(' ** Important: commit now to prevent restore from reverting the skill **');
119
+ console.log(` git add .cursor/ .cursor-guard.json && git commit -m "chore: install cursor-guard"\n`);
120
+ }
121
+
122
+ console.log(' If MCP was already configured, restart Cursor (or Ctrl+Shift+P ->');
114
123
  console.log(' "Developer: Reload Window") to load the updated MCP server.\n');
115
124
  console.log(' Next steps:');
116
125
  console.log(' 1. The skill activates automatically in Cursor Agent conversations.');
@@ -88,6 +88,13 @@ const I18N = {
88
88
  'drawer.field.hash': 'Commit Hash',
89
89
  'drawer.field.path': 'Path',
90
90
  'drawer.field.message': 'Message',
91
+ 'drawer.field.filesChanged': 'Files Changed',
92
+ 'drawer.field.summary': 'Change Summary',
93
+ 'drawer.field.trigger': 'Trigger',
94
+ 'trigger.auto': 'Auto (scheduled)',
95
+ 'trigger.manual': 'Manual (agent)',
96
+ 'trigger.pre-restore': 'Pre-Restore',
97
+ 'backups.col.summary': 'Changes',
91
98
 
92
99
  'error.fetchFailed': 'Failed to fetch data',
93
100
  'error.sectionFailed': 'This section failed to load',
@@ -254,6 +261,13 @@ const I18N = {
254
261
  'drawer.field.hash': '提交 Hash',
255
262
  'drawer.field.path': '路径',
256
263
  'drawer.field.message': '消息',
264
+ 'drawer.field.filesChanged': '变更文件数',
265
+ 'drawer.field.summary': '变更摘要',
266
+ 'drawer.field.trigger': '触发方式',
267
+ 'trigger.auto': '自动(定时)',
268
+ 'trigger.manual': '手动(Agent)',
269
+ 'trigger.pre-restore': '恢复前快照',
270
+ 'backups.col.summary': '变更',
257
271
 
258
272
  'error.fetchFailed': '数据拉取失败',
259
273
  'error.sectionFailed': '此区块加载失败',
@@ -779,6 +793,17 @@ function renderFilterBar() {
779
793
  ).join('');
780
794
  }
781
795
 
796
+ function formatSummaryCell(b) {
797
+ const parts = [];
798
+ if (b.filesChanged != null) parts.push(`<span class="text-sm">${b.filesChanged} files</span>`);
799
+ if (b.trigger) parts.push(`<span class="badge badge-trigger">${t('trigger.' + b.trigger)}</span>`);
800
+ if (b.summary) {
801
+ const short = b.summary.length > 60 ? b.summary.substring(0, 57) + '...' : b.summary;
802
+ parts.push(`<span class="text-muted text-sm">${esc(short)}</span>`);
803
+ }
804
+ return parts.length > 0 ? parts.join(' ') : '<span class="text-muted text-sm">-</span>';
805
+ }
806
+
782
807
  function renderBackupTable(backups) {
783
808
  if (!Array.isArray(backups)) {
784
809
  $('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
@@ -795,10 +820,12 @@ function renderBackupTable(backups) {
795
820
 
796
821
  const rows = state.filteredBackups.map((b, i) => {
797
822
  const badgeClass = b.type.startsWith('git') ? (b.type.includes('pre') ? 'badge-pre' : 'badge-git') : (b.type.includes('pre') ? 'badge-pre' : 'badge-shadow');
823
+ const summaryCell = formatSummaryCell(b);
798
824
  return `<tr data-bi="${i}">
799
825
  <td><div>${esc(formatTime(b.timestamp))}</div><div class="text-muted text-sm">${esc(relativeTime(b.timestamp))}</div></td>
800
826
  <td><span class="badge ${badgeClass}">${t('type.' + b.type)}</span></td>
801
827
  <td class="text-mono">${esc(b.shortHash || b.timestamp || '-')}</td>
828
+ <td class="backup-summary-cell">${summaryCell}</td>
802
829
  </tr>`;
803
830
  }).join('');
804
831
 
@@ -808,6 +835,7 @@ function renderBackupTable(backups) {
808
835
  <th>${t('backups.col.time')}</th>
809
836
  <th>${t('backups.col.type')}</th>
810
837
  <th>${t('backups.col.ref')}</th>
838
+ <th>${t('backups.col.summary')}</th>
811
839
  </tr></thead>
812
840
  <tbody>${rows}</tbody>
813
841
  </table>
@@ -895,10 +923,13 @@ function openRestoreDrawer(backup) {
895
923
  { key: 'drawer.field.time', val: formatTime(backup.timestamp) },
896
924
  { key: 'drawer.field.type', val: t('type.' + backup.type) },
897
925
  ];
926
+ if (backup.trigger) fields.push({ key: 'drawer.field.trigger', val: t('trigger.' + backup.trigger) });
927
+ if (backup.filesChanged != null) fields.push({ key: 'drawer.field.filesChanged', val: String(backup.filesChanged) });
898
928
  if (backup.ref) fields.push({ key: 'drawer.field.ref', val: backup.ref });
899
929
  if (backup.commitHash) fields.push({ key: 'drawer.field.hash', val: backup.commitHash });
900
930
  if (backup.path) fields.push({ key: 'drawer.field.path', val: backup.path });
901
931
  if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
932
+ if (backup.summary) fields.push({ key: 'drawer.field.summary', val: backup.summary });
902
933
 
903
934
  const refText = backup.ref || backup.shortHash || backup.timestamp || '';
904
935
  const jsonText = JSON.stringify(backup, null, 2);
@@ -244,9 +244,14 @@ main {
244
244
  .badge-warn { background: var(--yellow-bg); color: var(--yellow); }
245
245
  .badge-fail { background: var(--red-bg); color: var(--red); }
246
246
 
247
- .badge-git { background: var(--blue-bg); color: var(--blue); }
248
- .badge-shadow { background: var(--amber-bg); color: var(--amber); }
249
- .badge-pre { background: var(--purple-bg); color: var(--purple); }
247
+ .badge-git { background: var(--blue-bg); color: var(--blue); }
248
+ .badge-shadow { background: var(--amber-bg); color: var(--amber); }
249
+ .badge-pre { background: var(--purple-bg); color: var(--purple); }
250
+ .badge-trigger { background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.7rem; }
251
+
252
+ .backup-summary-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
253
+ .backup-summary-cell .badge-trigger { margin-right: 4px; }
254
+ .backup-summary-cell .text-sm { font-size: 0.75rem; }
250
255
 
251
256
  /* ── Stats Row ────────────────────────────────────────────── */
252
257
 
@@ -210,10 +210,10 @@ async function runBackup(projectDir, intervalOverride) {
210
210
 
211
211
  // V4: Record change event and check for anomalies
212
212
  let changedFileCount = 0;
213
+ let porcelain = '';
213
214
  if (repo) {
214
215
  // Use execFileSync directly — git() helper's trim() strips leading spaces
215
216
  // from porcelain output, corrupting the first line when it starts with ' '.
216
- let porcelain = '';
217
217
  try {
218
218
  porcelain = execFileSync('git', ['status', '--porcelain'], {
219
219
  cwd: projectDir, stdio: 'pipe', encoding: 'utf-8',
@@ -260,7 +260,12 @@ async function runBackup(projectDir, intervalOverride) {
260
260
 
261
261
  // Git snapshot via Core
262
262
  if ((cfg.backup_strategy === 'git' || cfg.backup_strategy === 'both') && repo) {
263
- const snapResult = createGitSnapshot(projectDir, cfg, { branchRef });
263
+ const context = { trigger: 'auto', changedFileCount };
264
+ if (porcelain) {
265
+ const pLines = porcelain.split('\n').filter(Boolean).slice(0, 20);
266
+ context.summary = pLines.map(l => l.substring(0, 2).trim() + ' ' + l.substring(3)).join(', ');
267
+ }
268
+ const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
264
269
  if (snapResult.status === 'created') {
265
270
  let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
266
271
  if (snapResult.secretsExcluded) {
@@ -41,6 +41,20 @@ function entryToMs(entry) {
41
41
  return d ? d.getTime() : 0;
42
42
  }
43
43
 
44
+ function parseCommitTrailers(body) {
45
+ if (!body) return {};
46
+ const result = {};
47
+ for (const line of body.split('\n')) {
48
+ const m = line.match(/^(Files-Changed|Summary|Trigger):\s*(.+)$/);
49
+ if (m) {
50
+ const key = m[1] === 'Files-Changed' ? 'filesChanged'
51
+ : m[1] === 'Summary' ? 'summary' : 'trigger';
52
+ result[key] = key === 'filesChanged' ? parseInt(m[2], 10) : m[2];
53
+ }
54
+ }
55
+ return result;
56
+ }
57
+
44
58
  // ── List backups ────────────────────────────────────────────────
45
59
 
46
60
  /**
@@ -52,7 +66,7 @@ function entryToMs(entry) {
52
66
  * @param {string} [opts.file] - Filter to commits touching this relative path
53
67
  * @param {string} [opts.before] - Time boundary (e.g. '10 minutes ago', ISO string)
54
68
  * @param {number} [opts.limit=20] - Max total results
55
- * @returns {{ sources: Array<{type: string, ref?: string, commitHash?: string, shortHash?: string, timestamp?: string, message?: string, path?: string}> }}
69
+ * @returns {{ sources: Array<{type: string, ref?: string, commitHash?: string, shortHash?: string, timestamp?: string, message?: string, path?: string, filesChanged?: number, summary?: string, trigger?: string}> }}
56
70
  */
57
71
  function listBackups(projectDir, opts = {}) {
58
72
  const limit = opts.limit || 20;
@@ -74,24 +88,27 @@ function listBackups(projectDir, opts = {}) {
74
88
  const autoRef = 'refs/guard/auto-backup';
75
89
  const autoExists = git(['rev-parse', '--verify', autoRef], { cwd: projectDir, allowFail: true });
76
90
  if (autoExists) {
77
- const logArgs = ['log', autoRef, `--format=%H %aI %s`, `-${limit}`, '--grep=^guard:'];
91
+ const logArgs = ['log', autoRef, '--format=%H\x1f%aI\x1f%B\x1e', `-${limit}`, '--grep=^guard:'];
78
92
  if (opts.before) logArgs.push(`--before=${opts.before}`);
79
93
  if (opts.file) logArgs.push('--', opts.file);
80
94
  const out = git(logArgs, { cwd: projectDir, allowFail: true });
81
95
  if (out) {
82
- for (const line of out.split('\n').filter(Boolean)) {
83
- const firstSpace = line.indexOf(' ');
84
- const secondSpace = line.indexOf(' ', firstSpace + 1);
85
- const hash = line.substring(0, firstSpace);
86
- const timestamp = line.substring(firstSpace + 1, secondSpace);
87
- const message = line.substring(secondSpace + 1);
96
+ for (const record of out.split('\x1e').filter(r => r.trim())) {
97
+ const parts = record.split('\x1f');
98
+ if (parts.length < 3) continue;
99
+ const hash = parts[0].trim();
100
+ const timestamp = parts[1];
101
+ const body = parts[2];
102
+ const subject = body.split('\n')[0];
103
+ const trailers = parseCommitTrailers(body);
88
104
  sources.push({
89
105
  type: 'git-auto-backup',
90
106
  ref: autoRef,
91
107
  commitHash: hash,
92
108
  shortHash: hash.substring(0, 7),
93
109
  timestamp,
94
- message,
110
+ message: subject,
111
+ ...trailers,
95
112
  });
96
113
  }
97
114
  }
@@ -112,20 +129,32 @@ function listBackups(projectDir, opts = {}) {
112
129
  const ms = Date.parse(timestamp);
113
130
  if (!isNaN(ms) && ms > beforeDate.getTime()) continue;
114
131
  }
115
- sources.push({
132
+ const entry = {
116
133
  type: 'git-pre-restore',
117
134
  ref,
118
135
  commitHash: hash,
119
136
  shortHash: hash.substring(0, 7),
120
137
  timestamp,
121
- });
138
+ };
139
+ const prBody = git(['log', '-1', '--format=%B', hash], { cwd: projectDir, allowFail: true });
140
+ if (prBody) {
141
+ const prSubject = prBody.split('\n')[0];
142
+ if (prSubject) entry.message = prSubject;
143
+ Object.assign(entry, parseCommitTrailers(prBody));
144
+ }
145
+ sources.push(entry);
122
146
  }
123
147
  }
124
148
 
125
149
  // Agent snapshot ref
126
150
  const snapshotHash = git(['rev-parse', '--verify', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
127
151
  if (snapshotHash) {
128
- const ts = git(['log', '-1', '--format=%aI', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
152
+ const snapLog = git(['log', '-1', '--format=%aI\x1f%B', 'refs/guard/snapshot'], { cwd: projectDir, allowFail: true });
153
+ const snapParts = snapLog ? snapLog.split('\x1f') : [];
154
+ const ts = snapParts[0] || null;
155
+ const snapBody = snapParts[1] || '';
156
+ const snapTrailers = parseCommitTrailers(snapBody);
157
+ const snapSubject = snapBody.split('\n')[0] || '';
129
158
  const include = !beforeDate || (ts && Date.parse(ts) <= beforeDate.getTime());
130
159
  if (include) {
131
160
  sources.push({
@@ -133,7 +162,9 @@ function listBackups(projectDir, opts = {}) {
133
162
  ref: 'refs/guard/snapshot',
134
163
  commitHash: snapshotHash,
135
164
  shortHash: snapshotHash.substring(0, 7),
136
- timestamp: ts || null,
165
+ timestamp: ts,
166
+ message: snapSubject || undefined,
167
+ ...snapTrailers,
137
168
  });
138
169
  }
139
170
  }
@@ -21,10 +21,13 @@ function validateRelativePath(file) {
21
21
  const VALID_SHADOW_SOURCE = /^\d{8}_\d{6}(_\d{3})?$|^pre-restore-\d{8}_\d{6}(_\d{3})?$/;
22
22
 
23
23
  const TOOL_DIRS = ['.cursor/', '.cursor\\'];
24
+ const GUARD_CONFIGS = ['.cursor-guard.json'];
24
25
 
25
26
  function isToolPath(filePath) {
26
27
  const normalized = filePath.replace(/\\/g, '/');
27
- return TOOL_DIRS.some(d => normalized.startsWith(d));
28
+ if (TOOL_DIRS.some(d => normalized.startsWith(d))) return true;
29
+ if (GUARD_CONFIGS.includes(normalized)) return true;
30
+ return false;
28
31
  }
29
32
 
30
33
  function validateShadowSource(source) {
@@ -217,7 +220,7 @@ function previewProjectRestore(projectDir, source) {
217
220
 
218
221
  for (const f of files) {
219
222
  if (isToolPath(f.path)) {
220
- f.warning = 'tool directoryrestoring may downgrade cursor-guard or other tools';
223
+ f.warning = 'protected pathwill be preserved from HEAD to prevent tool/config downgrade';
221
224
  }
222
225
  }
223
226
 
@@ -282,14 +285,16 @@ function executeProjectRestore(projectDir, source, opts = {}) {
282
285
  cwd: projectDir, stdio: 'pipe',
283
286
  });
284
287
 
285
- // Restore .cursor/ back from HEAD to prevent tool/skill downgrade
288
+ // Restore protected paths from HEAD to prevent tool/skill/config downgrade
286
289
  const head = git(['rev-parse', 'HEAD'], { cwd: projectDir, allowFail: true });
287
290
  if (head) {
288
- try {
289
- execFileSync('git', ['restore', `--source=HEAD`, '--', '.cursor/'], {
290
- cwd: projectDir, stdio: 'pipe',
291
- });
292
- } catch { /* .cursor/ may not exist in HEAD, that's fine */ }
291
+ for (const p of ['.cursor/', ...GUARD_CONFIGS]) {
292
+ try {
293
+ execFileSync('git', ['restore', `--source=HEAD`, '--', p], {
294
+ cwd: projectDir, stdio: 'pipe',
295
+ });
296
+ } catch { /* may not exist in HEAD, that's fine */ }
297
+ }
293
298
  }
294
299
 
295
300
  let untrackedCleaned = 0;
@@ -365,8 +370,10 @@ function createPreRestoreSnapshot(projectDir, scope) {
365
370
  return { status: 'skipped', reason: 'no changes to preserve' };
366
371
  }
367
372
 
373
+ let msg = `guard: pre-restore snapshot ${ts}`;
374
+ msg += '\n\nTrigger: pre-restore';
368
375
  const commitHash = execFileSync('git', [
369
- 'commit-tree', tree, '-p', head, '-m', `guard: pre-restore snapshot ${ts}`,
376
+ 'commit-tree', tree, '-p', head, '-m', msg,
370
377
  ], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
371
378
 
372
379
  if (!commitHash) return { status: 'error', error: 'commit-tree returned empty' };
@@ -52,6 +52,24 @@ function removeSecretsFromIndex(secretsPatterns, cwd, env) {
52
52
  return excluded;
53
53
  }
54
54
 
55
+ // ── Commit message builder ──────────────────────────────────────
56
+
57
+ function buildCommitMessage(ts, opts) {
58
+ if (opts.message && !opts.context) return opts.message;
59
+
60
+ const ctx = opts.context || {};
61
+ const countTag = ctx.changedFileCount ? ` (${ctx.changedFileCount} files)` : '';
62
+ const subject = opts.message || `guard: auto-backup ${ts}${countTag}`;
63
+
64
+ const trailers = [];
65
+ if (ctx.changedFileCount != null) trailers.push(`Files-Changed: ${ctx.changedFileCount}`);
66
+ if (ctx.summary) trailers.push(`Summary: ${ctx.summary}`);
67
+ if (ctx.trigger) trailers.push(`Trigger: ${ctx.trigger}`);
68
+
69
+ if (trailers.length === 0) return subject;
70
+ return subject + '\n\n' + trailers.join('\n');
71
+ }
72
+
55
73
  // ── Git snapshot ────────────────────────────────────────────────
56
74
 
57
75
  /**
@@ -63,6 +81,10 @@ function removeSecretsFromIndex(secretsPatterns, cwd, env) {
63
81
  * @param {object} [opts]
64
82
  * @param {string} [opts.branchRef='refs/guard/auto-backup']
65
83
  * @param {string} [opts.message] - Commit message (auto-generated if omitted)
84
+ * @param {object} [opts.context] - Backup context metadata
85
+ * @param {string} [opts.context.trigger] - 'auto' | 'manual' | 'pre-restore'
86
+ * @param {number} [opts.context.changedFileCount] - Number of changed files
87
+ * @param {string} [opts.context.summary] - Short change summary (e.g. "M src/app.js, A new.ts")
66
88
  * @returns {{ status: 'created'|'skipped'|'error', commitHash?: string, shortHash?: string, fileCount?: number, reason?: string, error?: string, secretsExcluded?: string[] }}
67
89
  */
68
90
  function createGitSnapshot(projectDir, cfg, opts = {}) {
@@ -109,7 +131,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
109
131
  }
110
132
 
111
133
  const ts = formatTimestamp(new Date());
112
- const msg = opts.message || `guard: auto-backup ${ts}`;
134
+ const msg = buildCommitMessage(ts, opts);
113
135
  const commitArgs = parentHash
114
136
  ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
115
137
  : ['commit-tree', newTree, '-m', msg];
@@ -112,6 +112,7 @@ server.tool(
112
112
  results.git = createGitSnapshot(resolved, cfg, {
113
113
  branchRef: 'refs/guard/snapshot',
114
114
  message: message || `guard: manual snapshot ${new Date().toISOString()}`,
115
+ context: { trigger: 'manual' },
115
116
  });
116
117
  }
117
118