@optimuslabs/harness-map-staging 1.5.36 → 1.5.39

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.
@@ -1,56 +1,131 @@
1
1
  /**
2
- * Skill block (docs/skill-block/DESIGN.md): lock the skill folders the organization blocked so no
3
- * agent can load or run them, and give a folder its permissions back once the skill is unblocked.
2
+ * Skill quarantine (docs/skill-block/DESIGN.md, "Quarantine"): move the skill folders the
3
+ * organization blocked into a vault, as an antivirus quarantines a file, and move them back once the
4
+ * skill is approved again.
4
5
  *
5
6
  * The remediation sync stores the server's list under `skill_blocks` in
6
- * remediation_instructions.json and then calls `applySkillBlocks`. A folder is locked with
7
- * `chmod 000` after its mode is recorded in skill_block_state.json; a recorded folder that is no
8
- * longer listed gets that mode back. The folders are where the collection found each skill's
9
- * SKILL.md, for any agent. A folder is locked only when it is a real directory inside the home
10
- * folder (never the home folder itself) holding a SKILL.md, so a bad path cannot lock anything
11
- * that is not a skill.
7
+ * remediation_instructions.json and then calls `applySkillBlocks`. A listed folder is moved into
8
+ * `~/.optimuslabs/management/quarantine/<id>/` with a receipt `<id>.json` (where it came from, when,
9
+ * and each file's hash, never its content); the vault entry is `chmod 000`, and an empty `chmod 000`
10
+ * folder is left at the original path so that no app (Claude Code re-syncing account skills, a
11
+ * plugin update) can put the skill back. A receipt whose folder is no longer listed is restored.
12
+ * A folder is only ever moved when it is a real directory inside the home folder (never the home
13
+ * folder itself) holding a SKILL.md, so a bad path cannot move anything that is not a skill.
12
14
  */
13
- import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
15
+ import { createHash } from 'node:crypto';
16
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
14
17
  import { homedir } from 'node:os';
15
- import { join, resolve, sep } from 'node:path';
18
+ import { dirname, join, resolve, sep } from 'node:path';
16
19
  import { complianceRunnerDiag, hookRunLog } from './hook_logger.js';
17
- import { atomicWriteJson, getSkillBlockStatePath } from './management_storage.js';
20
+ import { atomicWriteJson, getQuarantineDir, getSkillBlockStatePath, } from './management_storage.js';
18
21
  function log(message) {
19
22
  hookRunLog(message);
20
23
  complianceRunnerDiag(message);
21
24
  }
22
- export function readSkillBlockState() {
23
- const path = getSkillBlockStatePath();
24
- if (!existsSync(path))
25
- return {};
25
+ function permissionBits(path) {
26
+ return lstatSync(path).mode & 0o7777;
27
+ }
28
+ /** The vault id of an original path: one path, one entry. */
29
+ function entryId(dir) {
30
+ return createHash('sha256').update(resolve(dir)).digest('hex').slice(0, 16);
31
+ }
32
+ function vaultPath(id) {
33
+ return join(getQuarantineDir(), id);
34
+ }
35
+ function receiptPath(id) {
36
+ return join(getQuarantineDir(), `${id}.json`);
37
+ }
38
+ function readReceipt(id) {
26
39
  try {
27
- const parsed = JSON.parse(readFileSync(path, 'utf8'));
28
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
40
+ const parsed = JSON.parse(readFileSync(receiptPath(id), 'utf8'));
41
+ return parsed && typeof parsed.original_path === 'string' ? parsed : null;
29
42
  }
30
43
  catch {
31
- return {};
44
+ return null;
32
45
  }
33
46
  }
34
- function permissionBits(path) {
35
- return lstatSync(path).mode & 0o7777;
47
+ function writeReceipt(id, receipt) {
48
+ atomicWriteJson(receiptPath(id), receipt);
36
49
  }
37
- /**
38
- * Whether `dir` is a skill folder this endpoint locked for a block and that is still locked. Its
39
- * files cannot be read, and nothing can have changed in it since it was locked, so the collection
40
- * reports it as locked and the backend keeps the copy it has.
41
- */
42
- export function isLockedByOptimus(dir) {
43
- const target = resolve(dir);
44
- if (!readSkillBlockState()[target])
50
+ /** Every receipt in the vault, by id. */
51
+ function readReceipts() {
52
+ let names;
53
+ try {
54
+ names = readdirSync(getQuarantineDir());
55
+ }
56
+ catch {
57
+ return [];
58
+ }
59
+ const receipts = [];
60
+ for (const name of names) {
61
+ const match = name.match(/^([0-9a-f]{16})\.json$/);
62
+ const receipt = match ? readReceipt(match[1]) : null;
63
+ if (match && receipt)
64
+ receipts.push([match[1], receipt]);
65
+ }
66
+ return receipts;
67
+ }
68
+ function exists(path) {
69
+ try {
70
+ lstatSync(path);
71
+ return true;
72
+ }
73
+ catch {
45
74
  return false;
75
+ }
76
+ }
77
+ /** The empty `chmod 000` folder quarantine leaves at an original path. */
78
+ function isStub(dir) {
46
79
  try {
47
- return permissionBits(target) === 0;
80
+ const stat = lstatSync(dir);
81
+ return stat.isDirectory() && !stat.isSymbolicLink() && (stat.mode & 0o7777) === 0;
48
82
  }
49
83
  catch {
50
84
  return false;
51
85
  }
52
86
  }
53
- /** Why `dir` must not be locked, or null when it is a real skill folder inside the home folder. */
87
+ function makeStub(dir) {
88
+ mkdirSync(dir);
89
+ chmodSync(dir, 0o000);
90
+ }
91
+ /** Every file of `dir` with its hash, and every symlink with its target, sorted by path. */
92
+ function readFolderHashes(dir, prefix = '') {
93
+ const files = [];
94
+ for (const name of readdirSync(dir).sort()) {
95
+ const full = join(dir, name);
96
+ const rel = prefix ? `${prefix}/${name}` : name;
97
+ const stat = lstatSync(full);
98
+ if (stat.isSymbolicLink())
99
+ files.push({ path: rel, link: readlinkSync(full) });
100
+ else if (stat.isDirectory())
101
+ files.push(...readFolderHashes(full, rel));
102
+ else if (stat.isFile()) {
103
+ const sha256 = createHash('sha256').update(readFileSync(full)).digest('hex');
104
+ files.push({ path: rel, sha256, size: stat.size });
105
+ }
106
+ }
107
+ return files;
108
+ }
109
+ function sameFiles(a, b) {
110
+ return JSON.stringify(a) === JSON.stringify(b);
111
+ }
112
+ /** When the folder at `dir` was quarantined, or null when it is not. */
113
+ export function quarantinedAt(dir) {
114
+ const receipt = readReceipt(entryId(dir));
115
+ return receipt && receipt.status !== 'moving' ? receipt.quarantined_at : null;
116
+ }
117
+ /**
118
+ * The receipt of the skill folder quarantined from `dir`, when the empty folder quarantine left
119
+ * is still there. The collection reports such a path as quarantined: its skill cannot have changed
120
+ * since it was moved, and the server keeps the copy it has.
121
+ */
122
+ export function quarantineReceiptFor(dir) {
123
+ if (!isStub(resolve(dir)))
124
+ return null;
125
+ const receipt = readReceipt(entryId(dir));
126
+ return receipt && receipt.status !== 'moving' ? receipt : null;
127
+ }
128
+ /** Why `dir` must not be moved, or null when it is a real skill folder inside the home folder. */
54
129
  export function refusalReason(dir, home = homedir()) {
55
130
  const root = resolve(home);
56
131
  const target = resolve(dir);
@@ -69,24 +144,79 @@ export function refusalReason(dir, home = homedir()) {
69
144
  return 'no SKILL.md';
70
145
  return null;
71
146
  }
72
- /** Lock a recorded folder again if it was opened; its path may have been replaced, so check it. */
73
- function relockIfOpened(dir, entry, outcome, home) {
74
- if (!existsSync(dir) || permissionBits(dir) === 0)
147
+ /** Move a listed folder into the vault, with its receipt, and leave the empty folder behind. */
148
+ function moveIn(dir, id, entry, outcome) {
149
+ const receipt = {
150
+ original_path: dir,
151
+ resource_uuid: entry.resource_uuid,
152
+ skill_name: entry.skill_name,
153
+ quarantined_at: new Date().toISOString(),
154
+ mode: permissionBits(dir),
155
+ status: 'moving',
156
+ files: readFolderHashes(dir),
157
+ };
158
+ writeReceipt(id, receipt);
159
+ renameSync(dir, vaultPath(id));
160
+ finishMove(dir, id, receipt);
161
+ outcome.quarantined.push(dir);
162
+ }
163
+ /** The steps after the move, also run to finish one a crash interrupted. */
164
+ function finishMove(dir, id, receipt) {
165
+ if (!exists(dir))
166
+ makeStub(dir);
167
+ chmodSync(vaultPath(id), 0o000);
168
+ writeReceipt(id, { ...receipt, status: 'done' });
169
+ }
170
+ /**
171
+ * A listed folder already in the vault: keep its original path closed. The empty folder may have
172
+ * been removed, opened, or replaced by the skill itself (an app re-created it); a copy identical
173
+ * to the vault's is discarded, a different one is kept in the vault beside it.
174
+ */
175
+ function keepQuarantined(dir, id, receipt, home, outcome) {
176
+ if (isStub(dir))
177
+ return;
178
+ if (!exists(dir)) {
179
+ makeStub(dir);
180
+ outcome.requarantined.push(dir);
75
181
  return;
182
+ }
183
+ const stat = lstatSync(dir);
184
+ if (stat.isDirectory() && !stat.isSymbolicLink() && readdirSync(dir).length === 0) {
185
+ chmodSync(dir, 0o000);
186
+ outcome.requarantined.push(dir);
187
+ return;
188
+ }
76
189
  const reason = refusalReason(dir, home);
77
190
  if (reason) {
78
- outcome.refused.push({ skill_dir: entry.skill_dir, reason });
191
+ outcome.refused.push({ skill_dir: dir, reason });
79
192
  return;
80
193
  }
81
- chmodSync(dir, 0o000);
82
- outcome.relocked.push(dir);
194
+ if (sameFiles(readFolderHashes(dir), receipt.files)) {
195
+ rmSync(dir, { recursive: true, force: true });
196
+ }
197
+ else {
198
+ const kept = `${vaultPath(id)}.${Date.now()}`;
199
+ renameSync(dir, kept);
200
+ chmodSync(kept, 0o000);
201
+ log(`skill_block: a different copy came back at ${dir}; kept in the vault as ${kept}`);
202
+ }
203
+ makeStub(dir);
204
+ outcome.requarantined.push(dir);
83
205
  }
84
- /** Lock a listed folder, or lock it again when its permissions were opened since. */
85
- function lockFolder(entry, state, outcome, home) {
206
+ /** Quarantine one listed folder, or keep it quarantined. */
207
+ function quarantine(entry, home, outcome) {
86
208
  const dir = resolve(entry.skill_dir);
87
- const known = state[dir];
88
- if (known) {
89
- relockIfOpened(dir, entry, outcome, home);
209
+ const id = entryId(dir);
210
+ let receipt = readReceipt(id);
211
+ if (receipt?.status === 'moving') {
212
+ if (existsSync(vaultPath(id)))
213
+ finishMove(dir, id, receipt);
214
+ else
215
+ unlinkSync(receiptPath(id));
216
+ receipt = readReceipt(id);
217
+ }
218
+ if (receipt) {
219
+ keepQuarantined(dir, id, receipt, home, outcome);
90
220
  return;
91
221
  }
92
222
  const reason = refusalReason(dir, home);
@@ -94,28 +224,79 @@ function lockFolder(entry, state, outcome, home) {
94
224
  outcome.refused.push({ skill_dir: entry.skill_dir, reason });
95
225
  return;
96
226
  }
97
- state[dir] = { mode: permissionBits(dir), resource_uuid: entry.resource_uuid, locked_at: new Date().toISOString() };
98
- atomicWriteJson(getSkillBlockStatePath(), state);
99
- chmodSync(dir, 0o000);
100
- outcome.locked.push(dir);
227
+ moveIn(dir, id, entry, outcome);
228
+ }
229
+ /**
230
+ * Clear the original path for a restore: our empty folder (or an emptied one) is removed. Anything
231
+ * else there now is someone's copy: it is left alone and the vault entry is kept (a conflict).
232
+ */
233
+ function clearOriginalPath(dir) {
234
+ if (!exists(dir))
235
+ return true;
236
+ const stat = lstatSync(dir);
237
+ if (!stat.isDirectory() || stat.isSymbolicLink())
238
+ return false;
239
+ chmodSync(dir, 0o700);
240
+ if (readdirSync(dir).length > 0) {
241
+ chmodSync(dir, stat.mode & 0o7777);
242
+ return false;
243
+ }
244
+ rmdirSync(dir);
245
+ return true;
246
+ }
247
+ /** Move a folder no longer listed back where it came from, with its mode. */
248
+ function restore(id, receipt, outcome) {
249
+ const dir = receipt.original_path;
250
+ const vault = vaultPath(id);
251
+ if (!existsSync(vault)) {
252
+ unlinkSync(receiptPath(id));
253
+ log(`skill_block: the vault entry of ${dir} is missing; its receipt is dropped`);
254
+ return;
255
+ }
256
+ if (!clearOriginalPath(dir)) {
257
+ if (receipt.status !== 'conflict')
258
+ writeReceipt(id, { ...receipt, status: 'conflict' });
259
+ outcome.conflicts.push(dir);
260
+ return;
261
+ }
262
+ chmodSync(vault, receipt.mode);
263
+ if (!sameFiles(readFolderHashes(vault), receipt.files))
264
+ log(`skill_block: ${dir} changed in the vault; restored as it is`);
265
+ mkdirSync(dirname(dir), { recursive: true });
266
+ renameSync(vault, dir);
267
+ unlinkSync(receiptPath(id));
268
+ outcome.restored.push(dir);
101
269
  }
102
- /** Give a folder no longer listed the mode it had before it was locked. */
103
- function restoreFolder(dir, state, outcome) {
104
- const { mode } = state[dir];
105
- if (existsSync(dir) && !lstatSync(dir).isSymbolicLink()) {
106
- chmodSync(dir, mode);
107
- outcome.restored.push(dir);
270
+ /**
271
+ * The previous version locked folders in place (`chmod 000`, modes in skill_block_state.json).
272
+ * Give them their modes back once; the listed ones are then quarantined like any other.
273
+ */
274
+ function releaseInPlaceLocks() {
275
+ const path = getSkillBlockStatePath();
276
+ if (!existsSync(path))
277
+ return;
278
+ try {
279
+ const state = JSON.parse(readFileSync(path, 'utf8'));
280
+ for (const [dir, entry] of Object.entries(state)) {
281
+ if (typeof entry?.mode === 'number' && existsSync(dir) && !lstatSync(dir).isSymbolicLink()) {
282
+ chmodSync(dir, entry.mode);
283
+ }
284
+ }
285
+ }
286
+ catch (err) {
287
+ log(`skill_block: could not read the in-place locks: ${err instanceof Error ? err.message : String(err)}`);
108
288
  }
109
- delete state[dir];
289
+ unlinkSync(path);
110
290
  }
111
291
  /**
112
- * Make the folders on disk match `entries`, the server's whole list for this machine: lock the
113
- * listed ones, restore the ones locked before and no longer listed. A folder that fails the checks
114
- * is skipped and logged; one failing chmod does not stop the others.
292
+ * Make the vault match `entries`, the server's whole list for this machine: quarantine the listed
293
+ * folders, keep them quarantined, and restore the ones no longer listed. A folder that fails the
294
+ * checks is skipped and logged; one failure does not stop the others.
115
295
  */
116
296
  export function applySkillBlocks(entries, home = homedir()) {
117
- const outcome = { locked: [], relocked: [], restored: [], refused: [] };
118
- const lockPath = `${getSkillBlockStatePath()}.lock`;
297
+ const outcome = { quarantined: [], requarantined: [], restored: [], conflicts: [], refused: [] };
298
+ mkdirSync(getQuarantineDir(), { recursive: true, mode: 0o700 });
299
+ const lockPath = `${getQuarantineDir()}.lock`;
119
300
  if (!takeStateLock(lockPath)) {
120
301
  log('skill_block: another sync is applying blocks; the next sync applies these');
121
302
  return outcome;
@@ -129,9 +310,8 @@ export function applySkillBlocks(entries, home = homedir()) {
129
310
  return outcome;
130
311
  }
131
312
  /**
132
- * Take the lock around reading the state, changing permissions and writing the state. Two syncs at
133
- * once (the prompt gate and the compliance runner) could otherwise lose a folder's original mode,
134
- * leaving it locked after an unblock. A lock left by a dead process goes after a minute.
313
+ * Take the lock around the vault: two syncs at once (the prompt gate and the compliance runner)
314
+ * could otherwise move the same folder twice. A lock left by a dead process goes after a minute.
135
315
  */
136
316
  function takeStateLock(lockPath) {
137
317
  try {
@@ -153,36 +333,37 @@ function releaseStateLock(lockPath) {
153
333
  }
154
334
  }
155
335
  function applyUnderLock(entries, home, outcome) {
156
- const state = readSkillBlockState();
157
- const wanted = new Set(entries.map((entry) => resolve(entry.skill_dir)));
336
+ releaseInPlaceLocks();
337
+ const wanted = new Set(entries.map((entry) => entryId(entry.skill_dir)));
158
338
  for (const entry of entries) {
159
339
  try {
160
- lockFolder(entry, state, outcome, home);
340
+ quarantine(entry, home, outcome);
161
341
  }
162
342
  catch (err) {
163
343
  outcome.refused.push({ skill_dir: entry.skill_dir, reason: err instanceof Error ? err.message : String(err) });
164
344
  }
165
345
  }
166
- for (const dir of Object.keys(state)) {
167
- if (wanted.has(dir))
346
+ for (const [id, receipt] of readReceipts()) {
347
+ if (wanted.has(id))
168
348
  continue;
169
349
  try {
170
- restoreFolder(dir, state, outcome);
350
+ restore(id, receipt, outcome);
171
351
  }
172
352
  catch (err) {
173
- outcome.refused.push({ skill_dir: dir, reason: err instanceof Error ? err.message : String(err) });
353
+ outcome.refused.push({ skill_dir: receipt.original_path, reason: err instanceof Error ? err.message : String(err) });
174
354
  }
175
355
  }
176
- atomicWriteJson(getSkillBlockStatePath(), state);
177
356
  logOutcome(outcome);
178
357
  }
179
358
  function logOutcome(outcome) {
180
- for (const dir of outcome.locked)
181
- log(`skill_block: locked ${dir}`);
182
- for (const dir of outcome.relocked)
183
- log(`skill_block: locked again (permissions were opened) ${dir}`);
359
+ for (const dir of outcome.quarantined)
360
+ log(`skill_block: quarantined ${dir}`);
361
+ for (const dir of outcome.requarantined)
362
+ log(`skill_block: closed again ${dir}`);
184
363
  for (const dir of outcome.restored)
185
364
  log(`skill_block: restored ${dir}`);
365
+ for (const dir of outcome.conflicts)
366
+ log(`skill_block: not restored, something else is at ${dir}`);
186
367
  for (const { skill_dir, reason } of outcome.refused)
187
368
  log(`skill_block: skipped ${skill_dir} (${reason})`);
188
369
  }
@@ -6,12 +6,16 @@
6
6
  * The backend knows which sessions loaded a skill: the tool calls the skill usage view attributes to
7
7
  * it. The remediation sync sends this machine's sessions that loaded a blocked skill
8
8
  * (`skill_block_sessions` in remediation_instructions.json), and the prompt gate refuses their
9
- * prompts. Only those sessions are stopped; an unblock takes them off the list.
9
+ * prompts. `/clear` gives a new session id but the `claude` process keeps the skill, so the gate also
10
+ * records which session ids each process has run (skill_block_processes.json) and stops every session
11
+ * of a process that ran a listed one. An unblock takes the sessions off the list.
10
12
  */
11
13
  import { execFileSync } from 'node:child_process';
12
- import { basename, resolve } from 'node:path';
13
- import { readRemediationInstructionsFile } from './management_storage.js';
14
- import { readSkillBlockState } from './skill_block.js';
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { basename } from 'node:path';
16
+ import { atomicWriteJson, getSkillBlockProcessesPath, readRemediationInstructionsFile, } from './management_storage.js';
17
+ import { launchAgentAccessWindow } from '../../agent_access/window_runner.js';
18
+ import { quarantinedAt } from './skill_block.js';
15
19
  /** A process's parent, start time and command name, from `ps`; null when it cannot be read. */
16
20
  function processRow(pid) {
17
21
  try {
@@ -25,41 +29,82 @@ function processRow(pid) {
25
29
  }
26
30
  }
27
31
  /**
28
- * When the `claude` process running this hook started (ms), or null when it cannot be found. It
29
- * lists its skills when it starts and keeps them across `/clear`, so this is what tells a real
30
- * restart from `/clear`, which only changes the session id.
32
+ * The `claude` process running this hook, or null when it cannot be found. It lists its skills when
33
+ * it starts and keeps them across `/clear`, which only changes the session id, so the process (not
34
+ * the session) is what holds a loaded skill.
31
35
  */
32
- export function claudeProcessStartedAt() {
36
+ export function claudeProcess() {
33
37
  let pid = process.ppid;
34
38
  for (let depth = 0; depth < 12 && pid > 1; depth += 1) {
35
39
  const row = processRow(pid);
36
40
  if (!row)
37
41
  return null;
38
42
  if (row.name === 'claude')
39
- return row.startedAt;
43
+ return Number.isNaN(row.startedAt) ? null : { pid, startedAt: row.startedAt };
40
44
  pid = row.parent;
41
45
  }
42
46
  return null;
43
47
  }
44
- /** The names of the blocked skills this session loaded; empty when it may go on. */
45
- export function stoppingSkills(sessionId) {
48
+ /** When the `claude` process running this hook started (ms), or null when it cannot be found. */
49
+ export function claudeProcessStartedAt() {
50
+ return claudeProcess()?.startedAt ?? null;
51
+ }
52
+ /** The table's key: a pid with its start time, since the system reuses pids. */
53
+ function processKey(proc) {
54
+ return `${proc.pid}:${proc.startedAt}`;
55
+ }
56
+ function readProcessTable() {
57
+ const path = getSkillBlockProcessesPath();
58
+ if (!existsSync(path))
59
+ return {};
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
62
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
63
+ }
64
+ catch {
65
+ return {};
66
+ }
67
+ }
68
+ /**
69
+ * Record that `proc` runs `sessionId`, and drop processes not seen for 30 days (the server's window).
70
+ * Two prompts at once can each write the table and lose one session; the next prompt records it again.
71
+ */
72
+ export function recordSessionProcess(sessionId, proc, now = Date.now()) {
73
+ if (!sessionId || !proc)
74
+ return;
75
+ const table = readProcessTable();
76
+ const cutoff = now - 30 * 24 * 60 * 60 * 1000;
77
+ for (const [key, entry] of Object.entries(table)) {
78
+ if (!(Date.parse(entry?.last_seen ?? '') >= cutoff))
79
+ delete table[key];
80
+ }
81
+ const key = processKey(proc);
82
+ const sessions = new Set(table[key]?.sessions ?? []);
83
+ sessions.add(sessionId);
84
+ table[key] = { sessions: [...sessions], last_seen: new Date(now).toISOString() };
85
+ atomicWriteJson(getSkillBlockProcessesPath(), table);
86
+ }
87
+ /**
88
+ * The names of the blocked skills this session loaded, or its process loaded in an earlier session
89
+ * (before a `/clear`); empty when it may go on. Without the process, only the session id counts.
90
+ */
91
+ export function stoppingSkills(sessionId, proc = null) {
46
92
  if (!sessionId)
47
93
  return [];
94
+ const own = new Set([sessionId, ...(proc ? (readProcessTable()[processKey(proc)]?.sessions ?? []) : [])]);
48
95
  const sessions = readRemediationInstructionsFile().skill_block_sessions ?? [];
49
- return sessions.filter((s) => s.session_id === sessionId).map((s) => s.skill_name || 'a skill');
96
+ return sessions.filter((s) => own.has(s.session_id)).map((s) => s.skill_name || 'a skill');
50
97
  }
51
98
  /** The notice for a stopped session. */
52
99
  export function stoppedSessionMessage(skills) {
53
100
  const names = [...new Set(skills)].join(', ');
54
- return (`Your organization blocked ${names}, which this session loaded. Copy anything you need from ` +
55
- 'this conversation, then quit Claude Code and start it again to continue. Resuming this ' +
56
- 'session keeps it stopped.');
101
+ return `Your organization blocked ${names}. Quit Claude Code and start it again to clear this session's memory and continue.`;
57
102
  }
58
103
  /**
59
104
  * The blocked skill a typed slash command runs, when this process may still have it, or null:
60
- * `/airtable:airtable-cli …` or `/airtable-cli …`. A process started before the folder was locked
61
- * listed the skill and keeps it, even after `/clear`, so this refuses it. A process started after
62
- * the lock never listed it: this answers null, and Claude Code says the skill does not exist.
105
+ * `/airtable:airtable-cli …` or `/airtable-cli …`. A process started before the folder was
106
+ * quarantined listed the skill and keeps it, even after `/clear`, so this refuses it. A process
107
+ * started after it never listed it: this answers null, and Claude Code says the skill does not exist.
63
108
  * Matches the skill's name or its folder's name, which can differ (`aikido-issues` lives in
64
109
  * `skills/issues`). An unknown start time counts as too old, so a block is never skipped.
65
110
  */
@@ -68,19 +113,78 @@ export function blockedSkillOfCommand(promptFirstLine, processStartedAt = claude
68
113
  if (!command.startsWith('/'))
69
114
  return null;
70
115
  const typed = command.slice(command.lastIndexOf(':') + 1).replace(/^\//, '').toLowerCase();
71
- const blocks = readRemediationInstructionsFile().skill_blocks ?? [];
116
+ // A plugin skill only: a running Claude Code drops a custom skill by itself once it is quarantined.
117
+ const blocks = (readRemediationInstructionsFile().skill_blocks ?? []).filter((b) => isPluginSkillDir(b.skill_dir));
72
118
  const hits = blocks.filter((b) => b.skill_name.toLowerCase() === typed || basename(b.skill_dir).toLowerCase() === typed);
73
119
  if (hits.length === 0)
74
120
  return null;
75
121
  const startedAt = processStartedAt();
76
- const locks = readSkillBlockState();
77
122
  const mayStillHaveIt = hits.some((b) => {
78
- const lockedAt = Date.parse(locks[resolve(b.skill_dir)]?.locked_at ?? '');
123
+ const lockedAt = Date.parse(quarantinedAt(b.skill_dir) ?? '');
79
124
  return startedAt === null || Number.isNaN(lockedAt) || startedAt <= lockedAt;
80
125
  });
81
126
  return mayStillHaveIt ? hits[0].skill_name || typed : null;
82
127
  }
128
+ /**
129
+ * Whether `dir` is a Claude Code plugin's skill (`~/.claude/plugins/...`). Claude Code reads plugin
130
+ * skills once, when it starts, and keeps them until it quits, so a running one still offers a
131
+ * quarantined plugin skill. A custom skill (`~/.claude/skills`, a project's `.claude/skills`) is read
132
+ * again as its folder changes: Claude Code drops it at quarantine (docs/skill-block/DESIGN.md).
133
+ */
134
+ export function isPluginSkillDir(dir) {
135
+ return dir.includes('/.claude/plugins/');
136
+ }
83
137
  /** The notice for a typed command of a blocked skill in a process that may still have it. */
84
138
  export function blockedCommandMessage(skill) {
85
- return `Your organization blocked ${skill}. Quit Claude Code and start it again to continue.`;
139
+ return `Your organization blocked ${skill}. Quit Claude Code and start it again to clear this session's memory and continue.`;
140
+ }
141
+ /**
142
+ * The window to open beside the block of the skill named `skill` (a name `stoppingSkills` or
143
+ * `blockedSkillOfCommand` gave), as the sync sent it for that skill's resource; undefined when the
144
+ * server sent none, so only the one-line block shows.
145
+ */
146
+ export function skillBlockWindow(skill) {
147
+ const uuid = resourceUuidOf(skill);
148
+ return uuid ? readRemediationInstructionsFile().skill_block_windows?.[uuid] : undefined;
149
+ }
150
+ /** The resource uuid of the blocked skill named `skill`, from the synced lists; '' when unknown. */
151
+ function resourceUuidOf(skill) {
152
+ const file = readRemediationInstructionsFile();
153
+ const name = skill.toLowerCase();
154
+ const entry = [...(file.skill_block_sessions ?? []), ...(file.skill_blocks ?? [])].find((e) => e.resource_uuid && e.skill_name.toLowerCase() === name);
155
+ return entry?.resource_uuid ?? '';
156
+ }
157
+ /** The skills this run already opened the window for, so a prompt that quarantines a skill and is
158
+ * refused for it opens one window, not two. */
159
+ const windowsShown = new Set();
160
+ /**
161
+ * Open the window of one blocked skill, by resource uuid, once per run. True when it is open: just
162
+ * started, or already started this run; false when the server sent no window for it.
163
+ */
164
+ function openWindowOf(uuid) {
165
+ if (!uuid)
166
+ return false;
167
+ if (windowsShown.has(uuid))
168
+ return true;
169
+ const words = readRemediationInstructionsFile().skill_block_windows?.[uuid];
170
+ if (!words)
171
+ return false;
172
+ windowsShown.add(uuid);
173
+ return launchAgentAccessWindow('claude', words);
174
+ }
175
+ /** Open the window beside the block of the skill named `skill` (a stopped session, a typed command). */
176
+ export function openSkillBlockWindow(skill) {
177
+ return openWindowOf(resourceUuidOf(skill));
178
+ }
179
+ /**
180
+ * Open the window for each skill this sync moved into quarantine (`dirs`, the folders it moved), so
181
+ * the user hears of it when it happens, even when no session of theirs is known to have loaded it
182
+ * (a typed command whose turn made no tool call reaches the server as nothing). One window per
183
+ * skill however many of its folders moved.
184
+ */
185
+ export function openQuarantineWindows(dirs, entries) {
186
+ const moved = new Set(dirs);
187
+ const uuids = new Set(entries.filter((e) => moved.has(e.skill_dir)).map((e) => e.resource_uuid));
188
+ for (const uuid of uuids)
189
+ openWindowOf(uuid);
86
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optimuslabs/harness-map-staging",
3
- "version": "1.5.36",
3
+ "version": "1.5.39",
4
4
  "description": "CLI helpers for logging hardware UUIDs and posting startup payloads to Optimus Security.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "dialog_prefs-staging": "dist/cli.js"
16
16
  },
17
17
  "scripts": {
18
- "build": "tsc -p tsconfig.json && node ../../../scripts/ensure-bin-executable.mjs",
18
+ "build": "tsc -p tsconfig.json && node ../../../scripts/ensure-bin-executable.mjs && node -e \"const fs=require('fs');fs.mkdirSync('dist/agent_block_window',{recursive:true});fs.copyFileSync('src/agent_block_window/agent_block_window.jxa','dist/agent_block_window/agent_block_window.jxa')\"",
19
19
  "dev": "ts-node src/cli.ts",
20
20
  "lint": "eslint \"src/**/*.ts\"",
21
21
  "prepare": "npm run build",