@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.
- package/dist/agent_access/decision.js +20 -0
- package/dist/agent_access/window_runner.js +129 -0
- package/dist/agent_block_window/agent_block_window.jxa +223 -0
- package/dist/approved_tenant_gate.js +196 -105
- package/dist/compliance_prompt_gate.js +89 -26
- package/dist/log_config_files/collection/skill_symlink.js +9 -0
- package/dist/log_config_files/runtime/management_storage.js +34 -0
- package/dist/log_config_files/runtime/remediation_sync.js +64 -1
- package/dist/log_config_files/runtime/skill_block.js +253 -72
- package/dist/log_config_files/runtime/skill_block_sessions.js +76 -20
- package/package.json +2 -2
|
@@ -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
|
|
3
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
28
|
-
return parsed && typeof 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
|
|
35
|
-
|
|
47
|
+
function writeReceipt(id, receipt) {
|
|
48
|
+
atomicWriteJson(receiptPath(id), receipt);
|
|
36
49
|
}
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
73
|
-
function
|
|
74
|
-
|
|
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:
|
|
191
|
+
outcome.refused.push({ skill_dir: dir, reason });
|
|
79
192
|
return;
|
|
80
193
|
}
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
/**
|
|
85
|
-
function
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
289
|
+
unlinkSync(path);
|
|
110
290
|
}
|
|
111
291
|
/**
|
|
112
|
-
* Make the
|
|
113
|
-
*
|
|
114
|
-
* is skipped and logged; one
|
|
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 = {
|
|
118
|
-
|
|
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
|
|
133
|
-
*
|
|
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
|
-
|
|
157
|
-
const wanted = new Set(entries.map((entry) =>
|
|
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
|
-
|
|
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
|
|
167
|
-
if (wanted.has(
|
|
346
|
+
for (const [id, receipt] of readReceipts()) {
|
|
347
|
+
if (wanted.has(id))
|
|
168
348
|
continue;
|
|
169
349
|
try {
|
|
170
|
-
|
|
350
|
+
restore(id, receipt, outcome);
|
|
171
351
|
}
|
|
172
352
|
catch (err) {
|
|
173
|
-
outcome.refused.push({ skill_dir:
|
|
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.
|
|
181
|
-
log(`skill_block:
|
|
182
|
-
for (const dir of outcome.
|
|
183
|
-
log(`skill_block:
|
|
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
|
}
|