cursor-guard 4.2.2 → 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.
package/SKILL.md CHANGED
@@ -146,6 +146,15 @@ When the target file of an edit **falls outside the protected scope**, the agent
146
146
  **Before any High-risk operation on a protected file:**
147
147
 
148
148
  > **MCP shortcut**: if `snapshot_now` tool is available, call it with `{ "path": "<project>", "strategy": "git" }` instead of the shell commands below. The tool handles temp index, secrets exclusion, and ref creation internally, and returns `{ "git": { "status": "created", "commitHash": "...", "shortHash": "..." } }`. Report the `shortHash` to the user and proceed.
149
+ >
150
+ > **Best practice — descriptive messages**: Always provide a meaningful `message` parameter that describes *why* this snapshot is being created and *what* changes are at risk. This message appears in the dashboard restore-point list, helping users identify which snapshot to restore from. Example:
151
+ > ```json
152
+ > {
153
+ > "path": "/project",
154
+ > "strategy": "git",
155
+ > "message": "guard: before refactoring auth middleware — moving session logic from app.js to middleware/auth.js"
156
+ > }
157
+ > ```
149
158
 
150
159
  Use a **temporary index and dedicated ref** so the user's staged/unstaged state is never touched:
151
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "4.2.2",
3
+ "version": "4.3.1",
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",
@@ -88,6 +88,18 @@ 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',
98
+ 'summary.modified': 'Modified',
99
+ 'summary.added': 'Added',
100
+ 'summary.deleted': 'Deleted',
101
+ 'summary.renamed': 'Renamed',
102
+ 'summary.files': 'files',
91
103
 
92
104
  'error.fetchFailed': 'Failed to fetch data',
93
105
  'error.sectionFailed': 'This section failed to load',
@@ -254,6 +266,18 @@ const I18N = {
254
266
  'drawer.field.hash': '提交 Hash',
255
267
  'drawer.field.path': '路径',
256
268
  'drawer.field.message': '消息',
269
+ 'drawer.field.filesChanged': '变更文件数',
270
+ 'drawer.field.summary': '变更摘要',
271
+ 'drawer.field.trigger': '触发方式',
272
+ 'trigger.auto': '自动(定时)',
273
+ 'trigger.manual': '手动(Agent)',
274
+ 'trigger.pre-restore': '恢复前快照',
275
+ 'backups.col.summary': '变更',
276
+ 'summary.modified': '修改',
277
+ 'summary.added': '新增',
278
+ 'summary.deleted': '删除',
279
+ 'summary.renamed': '重命名',
280
+ 'summary.files': '个文件',
257
281
 
258
282
  'error.fetchFailed': '数据拉取失败',
259
283
  'error.sectionFailed': '此区块加载失败',
@@ -779,6 +803,31 @@ function renderFilterBar() {
779
803
  ).join('');
780
804
  }
781
805
 
806
+ function translateSummary(raw) {
807
+ if (!raw) return raw;
808
+ return raw
809
+ .replace(/\bModified (\d+)/g, (_, n) => `${t('summary.modified')} ${n}`)
810
+ .replace(/\bAdded (\d+)/g, (_, n) => `${t('summary.added')} ${n}`)
811
+ .replace(/\bDeleted (\d+)/g, (_, n) => `${t('summary.deleted')} ${n}`)
812
+ .replace(/\bRenamed (\d+)/g, (_, n) => `${t('summary.renamed')} ${n}`);
813
+ }
814
+
815
+ function formatSummaryCell(b) {
816
+ const parts = [];
817
+ if (b.filesChanged != null) parts.push(`<span class="text-sm">${b.filesChanged} ${t('summary.files')}</span>`);
818
+ if (b.trigger) parts.push(`<span class="badge badge-trigger">${t('trigger.' + b.trigger)}</span>`);
819
+ if (b.summary) {
820
+ const translated = translateSummary(b.summary);
821
+ const short = translated.length > 80 ? translated.substring(0, 77) + '...' : translated;
822
+ parts.push(`<span class="text-muted text-sm">${esc(short)}</span>`);
823
+ }
824
+ if (b.message && !b.message.startsWith('guard:')) {
825
+ const msgShort = b.message.length > 50 ? b.message.substring(0, 47) + '...' : b.message;
826
+ parts.push(`<span class="text-muted text-sm">${esc(msgShort)}</span>`);
827
+ }
828
+ return parts.length > 0 ? parts.join(' ') : '<span class="text-muted text-sm">-</span>';
829
+ }
830
+
782
831
  function renderBackupTable(backups) {
783
832
  if (!Array.isArray(backups)) {
784
833
  $('#backup-table-wrap').innerHTML = `<div class="error-panel">${t('error.sectionFailed')}</div>`;
@@ -795,10 +844,12 @@ function renderBackupTable(backups) {
795
844
 
796
845
  const rows = state.filteredBackups.map((b, i) => {
797
846
  const badgeClass = b.type.startsWith('git') ? (b.type.includes('pre') ? 'badge-pre' : 'badge-git') : (b.type.includes('pre') ? 'badge-pre' : 'badge-shadow');
847
+ const summaryCell = formatSummaryCell(b);
798
848
  return `<tr data-bi="${i}">
799
849
  <td><div>${esc(formatTime(b.timestamp))}</div><div class="text-muted text-sm">${esc(relativeTime(b.timestamp))}</div></td>
800
850
  <td><span class="badge ${badgeClass}">${t('type.' + b.type)}</span></td>
801
851
  <td class="text-mono">${esc(b.shortHash || b.timestamp || '-')}</td>
852
+ <td class="backup-summary-cell">${summaryCell}</td>
802
853
  </tr>`;
803
854
  }).join('');
804
855
 
@@ -808,6 +859,7 @@ function renderBackupTable(backups) {
808
859
  <th>${t('backups.col.time')}</th>
809
860
  <th>${t('backups.col.type')}</th>
810
861
  <th>${t('backups.col.ref')}</th>
862
+ <th>${t('backups.col.summary')}</th>
811
863
  </tr></thead>
812
864
  <tbody>${rows}</tbody>
813
865
  </table>
@@ -895,10 +947,13 @@ function openRestoreDrawer(backup) {
895
947
  { key: 'drawer.field.time', val: formatTime(backup.timestamp) },
896
948
  { key: 'drawer.field.type', val: t('type.' + backup.type) },
897
949
  ];
950
+ if (backup.trigger) fields.push({ key: 'drawer.field.trigger', val: t('trigger.' + backup.trigger) });
951
+ if (backup.filesChanged != null) fields.push({ key: 'drawer.field.filesChanged', val: String(backup.filesChanged) });
898
952
  if (backup.ref) fields.push({ key: 'drawer.field.ref', val: backup.ref });
899
953
  if (backup.commitHash) fields.push({ key: 'drawer.field.hash', val: backup.commitHash });
900
954
  if (backup.path) fields.push({ key: 'drawer.field.path', val: backup.path });
901
955
  if (backup.message) fields.push({ key: 'drawer.field.message', val: backup.message });
956
+ if (backup.summary) fields.push({ key: 'drawer.field.summary', val: translateSummary(backup.summary) });
902
957
 
903
958
  const refText = backup.ref || backup.shortHash || backup.timestamp || '';
904
959
  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,41 @@ 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
+ let pLines = porcelain.split('\n').filter(Boolean);
266
+ if (cfg.protect.length > 0 || cfg.ignore.length > 0) {
267
+ pLines = pLines.filter(line => {
268
+ const filePart = line.substring(3);
269
+ const arrowIdx = filePart.indexOf(' -> ');
270
+ const raw = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
271
+ const rel = unquoteGitPath(raw);
272
+ const fakeFile = { rel, full: path.join(projectDir, rel) };
273
+ return filterFiles([fakeFile], cfg).length > 0;
274
+ });
275
+ }
276
+ if (pLines.length > 0) {
277
+ const groups = { M: [], A: [], D: [], R: [] };
278
+ for (const l of pLines) {
279
+ const code = l.substring(0, 2).trim();
280
+ const filePart = l.substring(3);
281
+ const arrowIdx = filePart.indexOf(' -> ');
282
+ const file = arrowIdx >= 0 ? filePart.substring(arrowIdx + 4) : filePart;
283
+ const key = code.startsWith('R') ? 'R'
284
+ : code.includes('D') ? 'D'
285
+ : code.includes('A') || code === '??' ? 'A'
286
+ : 'M';
287
+ groups[key].push(file);
288
+ }
289
+ const parts = [];
290
+ if (groups.M.length) parts.push(`Modified ${groups.M.length}: ${groups.M.slice(0, 5).join(', ')}`);
291
+ if (groups.A.length) parts.push(`Added ${groups.A.length}: ${groups.A.slice(0, 5).join(', ')}`);
292
+ if (groups.D.length) parts.push(`Deleted ${groups.D.length}: ${groups.D.slice(0, 5).join(', ')}`);
293
+ if (groups.R.length) parts.push(`Renamed ${groups.R.length}: ${groups.R.slice(0, 5).join(', ')}`);
294
+ context.summary = parts.join('; ');
295
+ }
296
+ }
297
+ const snapResult = createGitSnapshot(projectDir, cfg, { branchRef, context });
264
298
  if (snapResult.status === 'created') {
265
299
  let msg = `Git snapshot ${snapResult.shortHash} (${snapResult.fileCount} files)`;
266
300
  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,7 +21,7 @@ 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
+ const GUARD_CONFIGS = ['.cursor-guard.json', '.gitignore'];
25
25
 
26
26
  function isToolPath(filePath) {
27
27
  const normalized = filePath.replace(/\\/g, '/');
@@ -370,8 +370,10 @@ function createPreRestoreSnapshot(projectDir, scope) {
370
370
  return { status: 'skipped', reason: 'no changes to preserve' };
371
371
  }
372
372
 
373
+ let msg = `guard: pre-restore snapshot ${ts}`;
374
+ msg += '\n\nTrigger: pre-restore';
373
375
  const commitHash = execFileSync('git', [
374
- 'commit-tree', tree, '-p', head, '-m', `guard: pre-restore snapshot ${ts}`,
376
+ 'commit-tree', tree, '-p', head, '-m', msg,
375
377
  ], { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim();
376
378
 
377
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 = {}) {
@@ -72,9 +94,11 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
72
94
  if (!gDir) return { status: 'error', error: 'not a git repository' };
73
95
 
74
96
  const guardIndex = path.join(gDir, 'cursor-guard-index');
97
+ const guardIndexLock = guardIndex + '.lock';
75
98
  const env = { ...process.env, GIT_INDEX_FILE: guardIndex };
76
99
 
77
100
  try { fs.unlinkSync(guardIndex); } catch { /* doesn't exist */ }
101
+ try { fs.unlinkSync(guardIndexLock); } catch { /* doesn't exist */ }
78
102
 
79
103
  try {
80
104
  const parentHash = git(['rev-parse', '--verify', branchRef], { cwd, allowFail: true });
@@ -109,7 +133,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
109
133
  }
110
134
 
111
135
  const ts = formatTimestamp(new Date());
112
- const msg = opts.message || `guard: auto-backup ${ts}`;
136
+ const msg = buildCommitMessage(ts, opts);
113
137
  const commitArgs = parentHash
114
138
  ? ['commit-tree', newTree, '-p', parentHash, '-m', msg]
115
139
  : ['commit-tree', newTree, '-m', msg];
@@ -142,6 +166,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
142
166
  return { status: 'error', error: e.message };
143
167
  } finally {
144
168
  try { fs.unlinkSync(guardIndex); } catch { /* ignore */ }
169
+ try { fs.unlinkSync(guardIndexLock); } catch { /* ignore */ }
145
170
  }
146
171
  }
147
172
 
@@ -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