@optimuslabs/harness-map-staging 1.5.35 → 1.5.38

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.
@@ -77,6 +77,18 @@ export function getRemediationStateDir() {
77
77
  export function getRemediationInstructionsPath() {
78
78
  return join(getRemediationStateDir(), REMEDIATION_INSTRUCTIONS_BASENAME);
79
79
  }
80
+ /** The mode each locked skill folder had before it was locked, so unblocking can restore it. */
81
+ export function getSkillBlockStatePath() {
82
+ return join(getRemediationStateDir(), 'skill_block_state.json');
83
+ }
84
+ /** The vault of quarantined skill folders and their receipts (docs/skill-block, "Quarantine"). */
85
+ export function getQuarantineDir() {
86
+ return join(getManagementDir(), 'quarantine');
87
+ }
88
+ /** The session ids each `claude` process has run, so a `/clear` cannot escape a skill block stop. */
89
+ export function getSkillBlockProcessesPath() {
90
+ return join(getRemediationStateDir(), 'skill_block_processes.json');
91
+ }
80
92
  export function getDeferredVscdbApplyPath() {
81
93
  return join(getVscdbStateDir(), DEFERRED_VSCDB_APPLY_BASENAME);
82
94
  }
@@ -139,12 +151,18 @@ function writeInstructionsFile(file, remediationsWrittenAt) {
139
151
  remediations_written_at: remediationsWrittenAt,
140
152
  });
141
153
  }
154
+ // The lists the sync replaces on every answer (the skill blocks with them); a writer that passes
155
+ // only `remediations` keeps them.
142
156
  const APPROVAL_LISTS = [
143
157
  'approved_tenants',
144
158
  'unapproved_email_domains',
145
159
  'allowed_email_domains',
146
160
  'approved_agents',
147
161
  'team_email_domains',
162
+ 'skill_blocks',
163
+ 'skill_block_sessions',
164
+ 'skill_block_windows',
165
+ 'agent_access_windows',
148
166
  ];
149
167
  /** Each approval list from `file` when it names one, else the one on disk; absent ones left out. */
150
168
  function keptApprovalLists(file, onDisk) {
@@ -172,6 +190,10 @@ function writeApprovalList(patch) {
172
190
  export function writeUnapprovedEmailDomains(unapproved_email_domains) {
173
191
  writeApprovalList({ unapproved_email_domains });
174
192
  }
193
+ /** Replace only the blocked-agent windows, keeping everything else on disk. */
194
+ export function writeAgentAccessWindows(agent_access_windows) {
195
+ writeApprovalList({ agent_access_windows });
196
+ }
175
197
  /** Replace only the approved agents, keeping everything else on disk. */
176
198
  export function writeApprovedAgents(approved_agents) {
177
199
  writeApprovalList({ approved_agents });
@@ -184,6 +206,18 @@ export function writeTeamEmailDomains(team_email_domains) {
184
206
  export function writeAllowedEmailDomains(allowed_email_domains) {
185
207
  writeApprovalList({ allowed_email_domains });
186
208
  }
209
+ /** Replace only the skill blocks, keeping everything else on disk. */
210
+ export function writeSkillBlocks(skill_blocks) {
211
+ writeApprovalList({ skill_blocks });
212
+ }
213
+ /** Replace only the sessions to stop for skill blocks, keeping everything else on disk. */
214
+ export function writeSkillBlockSessions(skill_block_sessions) {
215
+ writeApprovalList({ skill_block_sessions });
216
+ }
217
+ /** Replace only the skill block windows, keeping everything else on disk. */
218
+ export function writeSkillBlockWindows(skill_block_windows) {
219
+ writeApprovalList({ skill_block_windows });
220
+ }
187
221
  /** Replace only the approved-tenant list, keeping the remediations already on disk. */
188
222
  export function writeApprovedTenants(approved_tenants) {
189
223
  writeApprovalList({ approved_tenants });
@@ -4,7 +4,8 @@ import { homedir } from 'node:os';
4
4
  import { executeBody } from '../../endpoint_client/http_transport.js';
5
5
  import { parseJsonWithJsoncFallback, parseTomlText, stringifyToml } from '../readers/file_readers.js';
6
6
  import { complianceRunnerDiag, hookRunLog, logRemediationApplyFailure } from './hook_logger.js';
7
- import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeTeamEmailDomains, writeApprovedAgents, writeRemediationInstructionsFile, } from './management_storage.js';
7
+ import { atomicWriteJson, getDeferredVscdbApplyPath, getFileCollectionVscdbContractPath, getRemediationInstructionsPath, readRemediationInstructionsFile, writeApprovedTenants, writeUnapprovedEmailDomains, writeAllowedEmailDomains, writeTeamEmailDomains, writeApprovedAgents, writeAgentAccessWindows, writeRemediationInstructionsFile, writeSkillBlockSessions, writeSkillBlockWindows, writeSkillBlocks, } from './management_storage.js';
8
+ import { applySkillBlocks } from './skill_block.js';
8
9
  import { readStoredAuthKey } from '../auth/auth_key_store.js';
9
10
  import { ensureAuthentication } from '../auth/auth_flow.js';
10
11
  import { createSignature } from '../sender/signing.js';
@@ -1656,6 +1657,67 @@ function storeApprovals(syncResult) {
1656
1657
  const teamDomains = syncResult.team_email_domains;
1657
1658
  if (Array.isArray(teamDomains))
1658
1659
  writeTeamEmailDomains(lowercaseStrings(teamDomains));
1660
+ storeAgentAccess(syncResult);
1661
+ }
1662
+ /** The blocked-agent window's words for each agent that is not approved. */
1663
+ function storeAgentAccess(syncResult) {
1664
+ const windows = syncResult.agent_access_windows;
1665
+ if (windows && typeof windows === 'object' && !Array.isArray(windows))
1666
+ writeAgentAccessWindows(windows);
1667
+ }
1668
+ function isSkillBlockEntry(value) {
1669
+ if (!value || typeof value !== 'object')
1670
+ return false;
1671
+ const entry = value;
1672
+ return typeof entry.skill_dir === 'string' && entry.skill_dir !== '' && typeof entry.resource_uuid === 'string';
1673
+ }
1674
+ /** Keep the server's sessions to stop (for the prompt gate); an older server sends none. */
1675
+ function storeSkillBlockSessions(sessions) {
1676
+ if (!Array.isArray(sessions))
1677
+ return;
1678
+ writeSkillBlockSessions(sessions
1679
+ .filter((s) => typeof s?.session_id === 'string' && s.session_id !== '')
1680
+ .map((s) => ({
1681
+ session_id: s.session_id,
1682
+ resource_uuid: typeof s.resource_uuid === 'string' ? s.resource_uuid : '',
1683
+ skill_name: typeof s.skill_name === 'string' ? s.skill_name : '',
1684
+ })));
1685
+ }
1686
+ /** Keep the server's skill block windows (words only, drawn as sent); an older server sends none. */
1687
+ function storeSkillBlockWindows(windows) {
1688
+ if (!windows || typeof windows !== 'object' || Array.isArray(windows))
1689
+ return;
1690
+ const kept = {};
1691
+ for (const [uuid, words] of Object.entries(windows)) {
1692
+ if (words && typeof words === 'object' && !Array.isArray(words))
1693
+ kept[uuid] = words;
1694
+ }
1695
+ writeSkillBlockWindows(kept);
1696
+ }
1697
+ /**
1698
+ * Store the server's skill blocks and make the folders on disk match them. An older server sends
1699
+ * no `skill_blocks`: the folders are then left as they are, never unlocked by mistake.
1700
+ */
1701
+ function enforceSkillBlocks(syncResult) {
1702
+ const blocks = syncResult.skill_blocks;
1703
+ if (!Array.isArray(blocks))
1704
+ return;
1705
+ const entries = blocks.filter(isSkillBlockEntry).map((entry) => ({
1706
+ resource_uuid: entry.resource_uuid,
1707
+ skill_name: typeof entry.skill_name === 'string' ? entry.skill_name : '',
1708
+ skill_dir: entry.skill_dir,
1709
+ }));
1710
+ writeSkillBlocks(entries);
1711
+ storeSkillBlockSessions(syncResult.skill_block_sessions);
1712
+ storeSkillBlockWindows(syncResult.skill_block_windows);
1713
+ try {
1714
+ applySkillBlocks(entries);
1715
+ }
1716
+ catch (err) {
1717
+ const msg = `skill_block_error: ${errorText(err)}`;
1718
+ hookRunLog(msg);
1719
+ complianceRunnerDiag(msg);
1720
+ }
1659
1721
  }
1660
1722
  async function reconcileInstructions(endpointBase, machineUuid, want) {
1661
1723
  try {
@@ -1792,6 +1854,7 @@ export async function syncRemediations(endpointBase, machineUuid) {
1792
1854
  return;
1793
1855
  applyClearApplyTrackingUuids(syncResult.clear_apply_tracking_uuids);
1794
1856
  storeApprovals(syncResult);
1857
+ enforceSkillBlocks(syncResult);
1795
1858
  if (syncResult.status === 'unchanged') {
1796
1859
  await reconcileUnchanged(endpointBase, machineUuid, remediations, activeUuids);
1797
1860
  return;
@@ -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 hashFolder(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(...hashFolder(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: hashFolder(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(hashFolder(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(hashFolder(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
  }