claude-dev-env 8.16.0 → 8.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/windows-scheduled-task/SKILL.md +24 -0
- package/bin/install-plan.mjs +9 -8
- package/bin/install.mjs +51 -50
- package/bin/install.settings-defaults.test.mjs +37 -12
- package/bin/merge_managed_permissions.mjs +121 -51
- package/hooks/hooks_constants/working_style_prompt_constants.py +3 -1
- package/hooks/session/test_working_style_prompt.py +2 -0
- package/package.json +1 -1
- package/rules/review-closure-is-a-check.md +2 -0
- package/settings.json +7 -1
|
@@ -20,6 +20,7 @@ description: >-
|
|
|
20
20
|
- Status
|
|
21
21
|
- Removal and teardown
|
|
22
22
|
- Self-modification warning
|
|
23
|
+
- Handing the elevated step to the operator
|
|
23
24
|
- Verification
|
|
24
25
|
- Files
|
|
25
26
|
|
|
@@ -36,6 +37,8 @@ prove the task by the artifact it changes.
|
|
|
36
37
|
- Task Scheduler inherits none of the shell PATH. A bare executable name in the action fails at run time.
|
|
37
38
|
- A helper under an installer-managed path deletes itself during an installer run.
|
|
38
39
|
- `LastTaskResult: 0` from a run that took the idle branch says nothing about the branch that acts.
|
|
40
|
+
- A `#Requires -RunAsAdministrator` line pasted at a prompt checks nothing. It applies only to a script file.
|
|
41
|
+
- An elevated window opens in `C:\WINDOWS\system32`. A prompt in the user's home folder is usually unelevated.
|
|
39
42
|
|
|
40
43
|
## When this applies
|
|
41
44
|
|
|
@@ -134,6 +137,27 @@ instructions on a timer. A major upgrade can land mid-session while the agent ho
|
|
|
134
137
|
Give the operator the teardown command, name the interval, and keep the interval wide enough for a
|
|
135
138
|
session to finish between runs.
|
|
136
139
|
|
|
140
|
+
## Handing the elevated step to the operator
|
|
141
|
+
|
|
142
|
+
The operator runs the elevated part by hand, so hand it over as one block they paste into an
|
|
143
|
+
Administrator window. Put the block in the message itself. A file to download adds a step and a
|
|
144
|
+
path the browser can rename.
|
|
145
|
+
|
|
146
|
+
Wrap the block in `& { ... }` and open it with an elevation check, so an unelevated paste stops
|
|
147
|
+
with a clear line and changes nothing:
|
|
148
|
+
|
|
149
|
+
```powershell
|
|
150
|
+
& {
|
|
151
|
+
$ErrorActionPreference = 'Stop'
|
|
152
|
+
$IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
153
|
+
if (-not $IsAdmin) { Write-Host 'NOT ELEVATED. Open PowerShell with Run as administrator and paste again.' -ForegroundColor Red; return }
|
|
154
|
+
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Principal $Principal -Force
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The script block scopes `$ErrorActionPreference` and lets `return` end the paste. End the block
|
|
159
|
+
with a status print the operator copies back, so the next step reads the result.
|
|
160
|
+
|
|
137
161
|
## Verification
|
|
138
162
|
|
|
139
163
|
A run that takes the idle branch reports `LastTaskResult: 0`, and that result covers the idle branch
|
package/bin/install-plan.mjs
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { SETTINGS_FILE_NAME } from './install-constants.mjs';
|
|
22
|
+
import {
|
|
23
|
+
emptyManagedPermissions,
|
|
24
|
+
managedPermissionsFromPackageSettings,
|
|
25
|
+
} from './merge_managed_permissions.mjs';
|
|
22
26
|
|
|
23
27
|
export const PREFLIGHT_ERROR_CODES = Object.freeze({
|
|
24
28
|
SOURCE_CONFLICTS: 'source_conflicts',
|
|
@@ -353,7 +357,7 @@ export function buildUninstallPlan(input) {
|
|
|
353
357
|
removableFiles: Object.freeze([]),
|
|
354
358
|
skippedFiles: Object.freeze([]),
|
|
355
359
|
allManifestFiles: Object.freeze([]),
|
|
356
|
-
|
|
360
|
+
managedPermissions: Object.freeze(emptyManagedPermissions()),
|
|
357
361
|
isNoOp: true,
|
|
358
362
|
});
|
|
359
363
|
}
|
|
@@ -374,12 +378,9 @@ export function buildUninstallPlan(input) {
|
|
|
374
378
|
const allManifestFiles = Array.isArray(rawFiles)
|
|
375
379
|
? rawFiles.filter((eachPath) => typeof eachPath === 'string' && eachPath.trim() !== '')
|
|
376
380
|
: [];
|
|
377
|
-
const
|
|
378
|
-
? manifest[MANIFEST_MANAGED_PERMISSIONS_KEY]
|
|
379
|
-
|
|
380
|
-
const managedPermissionDenyEntries = Array.isArray(rawManagedDeny)
|
|
381
|
-
? rawManagedDeny.filter((eachEntry) => typeof eachEntry === 'string' && eachEntry.trim() !== '')
|
|
382
|
-
: [];
|
|
381
|
+
const managedPermissions = managedPermissionsFromPackageSettings({
|
|
382
|
+
permissions: isManifestObject ? manifest[MANIFEST_MANAGED_PERMISSIONS_KEY] : null,
|
|
383
|
+
});
|
|
383
384
|
|
|
384
385
|
/** @type {string[]} */
|
|
385
386
|
const removableFiles = [];
|
|
@@ -401,7 +402,7 @@ export function buildUninstallPlan(input) {
|
|
|
401
402
|
removableFiles: Object.freeze(removableFiles),
|
|
402
403
|
skippedFiles: Object.freeze(skippedFiles),
|
|
403
404
|
allManifestFiles: Object.freeze(allManifestFiles),
|
|
404
|
-
|
|
405
|
+
managedPermissions: Object.freeze(managedPermissions),
|
|
405
406
|
isNoOp: false,
|
|
406
407
|
});
|
|
407
408
|
}
|
package/bin/install.mjs
CHANGED
|
@@ -17,9 +17,13 @@ import {
|
|
|
17
17
|
} from './expand_home_directory_tokens.mjs';
|
|
18
18
|
import { EVER_SHIPPED_SKILL_NAMES } from './ever-shipped-skills.mjs';
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
MANAGED_PERMISSION_LIST_NAMES,
|
|
21
|
+
countManagedPermissions,
|
|
22
|
+
emptyManagedPermissions,
|
|
23
|
+
managedPermissionsFromPackageSettings,
|
|
21
24
|
mergeManagedPermissionsIntoSettings,
|
|
22
25
|
pruneManagedPermissionsFromSettings,
|
|
26
|
+
retiredManagedPermissions,
|
|
23
27
|
} from './merge_managed_permissions.mjs';
|
|
24
28
|
import {
|
|
25
29
|
SKIPPED_SOURCE_ENTRY_NAMES,
|
|
@@ -2085,84 +2089,87 @@ function writeManifest(installedFiles, skillNames, managedPermissions = null) {
|
|
|
2085
2089
|
files: installedFiles,
|
|
2086
2090
|
skills: skillNames,
|
|
2087
2091
|
});
|
|
2088
|
-
if (managedPermissions &&
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
+
if (managedPermissions && countManagedPermissions(managedPermissions) > 0) {
|
|
2093
|
+
const manifestPermissions = {};
|
|
2094
|
+
for (const eachListName of MANAGED_PERMISSION_LIST_NAMES) {
|
|
2095
|
+
if (managedPermissions[eachListName].length > 0) {
|
|
2096
|
+
manifestPermissions[eachListName] = [...managedPermissions[eachListName]];
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
manifest[MANIFEST_MANAGED_PERMISSIONS_KEY] = manifestPermissions;
|
|
2092
2100
|
}
|
|
2093
2101
|
writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2) + '\n');
|
|
2094
2102
|
}
|
|
2095
2103
|
|
|
2096
2104
|
/**
|
|
2097
|
-
* Load package-owned deny
|
|
2105
|
+
* Load package-owned allow and deny rules from the package settings.json source.
|
|
2098
2106
|
*
|
|
2099
|
-
* @returns {
|
|
2107
|
+
* @returns {import('./merge_managed_permissions.mjs').ManagedPermissions}
|
|
2100
2108
|
*/
|
|
2101
|
-
function
|
|
2109
|
+
function loadPackageManagedPermissions() {
|
|
2102
2110
|
const packageSettingsPath = join(PACKAGE_ROOT, SETTINGS_FILE_NAME);
|
|
2103
2111
|
if (!existsSync(packageSettingsPath)) {
|
|
2104
|
-
return
|
|
2112
|
+
return emptyManagedPermissions();
|
|
2105
2113
|
}
|
|
2106
2114
|
try {
|
|
2107
2115
|
const packageSettings = JSON.parse(readFileSync(packageSettingsPath, 'utf8'));
|
|
2108
|
-
return
|
|
2116
|
+
return managedPermissionsFromPackageSettings(packageSettings);
|
|
2109
2117
|
} catch (parseError) {
|
|
2110
2118
|
console.warn(
|
|
2111
2119
|
` Warning: could not read package ${SETTINGS_FILE_NAME} for managed permissions (${parseError.message})`,
|
|
2112
2120
|
);
|
|
2113
|
-
return
|
|
2121
|
+
return emptyManagedPermissions();
|
|
2114
2122
|
}
|
|
2115
2123
|
}
|
|
2116
2124
|
|
|
2117
2125
|
/**
|
|
2118
|
-
* Load deny
|
|
2126
|
+
* Load the allow and deny rules an earlier manifest records as package-managed.
|
|
2119
2127
|
*
|
|
2120
|
-
* @returns {
|
|
2128
|
+
* @returns {import('./merge_managed_permissions.mjs').ManagedPermissions}
|
|
2121
2129
|
*/
|
|
2122
|
-
function
|
|
2123
|
-
if (!existsSync(MANIFEST_FILE)) return
|
|
2130
|
+
function loadPriorManagedPermissions() {
|
|
2131
|
+
if (!existsSync(MANIFEST_FILE)) return emptyManagedPermissions();
|
|
2124
2132
|
try {
|
|
2125
2133
|
const priorManifest = JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
|
|
2126
|
-
return
|
|
2134
|
+
return managedPermissionsFromPackageSettings({
|
|
2127
2135
|
permissions: priorManifest?.[MANIFEST_MANAGED_PERMISSIONS_KEY],
|
|
2128
2136
|
});
|
|
2129
2137
|
} catch {
|
|
2130
|
-
return
|
|
2138
|
+
return emptyManagedPermissions();
|
|
2131
2139
|
}
|
|
2132
2140
|
}
|
|
2133
2141
|
|
|
2134
2142
|
/**
|
|
2135
2143
|
* Reconcile package-managed permission defaults with ~/.claude/settings.json.
|
|
2136
2144
|
*
|
|
2137
|
-
* @returns {{addedCount: number, alreadyPresentCount: number, removedCount: number,
|
|
2145
|
+
* @returns {{addedCount: number, alreadyPresentCount: number, removedCount: number, managedPermissions: import('./merge_managed_permissions.mjs').ManagedPermissions}}
|
|
2138
2146
|
*/
|
|
2139
2147
|
function mergeManagedPermissions() {
|
|
2140
|
-
const
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
eachEntry => !allCurrentManagedDenyEntrySet.has(eachEntry),
|
|
2148
|
+
const currentManagedPermissions = loadPackageManagedPermissions();
|
|
2149
|
+
const retiredPermissions = retiredManagedPermissions(
|
|
2150
|
+
loadPriorManagedPermissions(),
|
|
2151
|
+
currentManagedPermissions,
|
|
2145
2152
|
);
|
|
2146
|
-
const
|
|
2147
|
-
const
|
|
2148
|
-
if (!
|
|
2153
|
+
const hasCurrentManagedPermissions = countManagedPermissions(currentManagedPermissions) > 0;
|
|
2154
|
+
const hasRetiredManagedPermissions = countManagedPermissions(retiredPermissions) > 0;
|
|
2155
|
+
if (!hasCurrentManagedPermissions && !hasRetiredManagedPermissions) {
|
|
2149
2156
|
return {
|
|
2150
2157
|
addedCount: 0,
|
|
2151
2158
|
alreadyPresentCount: 0,
|
|
2152
2159
|
removedCount: 0,
|
|
2153
|
-
|
|
2160
|
+
managedPermissions: emptyManagedPermissions(),
|
|
2154
2161
|
};
|
|
2155
2162
|
}
|
|
2156
2163
|
const settingsPath = join(CLAUDE_HOME, SETTINGS_FILE_NAME);
|
|
2157
2164
|
const settingsExisted = existsSync(settingsPath);
|
|
2158
2165
|
const settings = loadClaudeSettingsObject(settingsPath);
|
|
2159
|
-
const pruneOutcome = pruneManagedPermissionsFromSettings(settings,
|
|
2160
|
-
const mergeOutcome =
|
|
2161
|
-
? mergeManagedPermissionsIntoSettings(settings,
|
|
2162
|
-
: { addedCount: 0, alreadyPresentCount: 0,
|
|
2166
|
+
const pruneOutcome = pruneManagedPermissionsFromSettings(settings, retiredPermissions);
|
|
2167
|
+
const mergeOutcome = hasCurrentManagedPermissions
|
|
2168
|
+
? mergeManagedPermissionsIntoSettings(settings, currentManagedPermissions)
|
|
2169
|
+
: { addedCount: 0, alreadyPresentCount: 0, managedPermissions: emptyManagedPermissions() };
|
|
2163
2170
|
const shouldWriteSettings = pruneOutcome.removedCount > 0
|
|
2164
2171
|
|| mergeOutcome.addedCount > 0
|
|
2165
|
-
|| (!settingsExisted &&
|
|
2172
|
+
|| (!settingsExisted && hasCurrentManagedPermissions);
|
|
2166
2173
|
if (shouldWriteSettings) {
|
|
2167
2174
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 4) + '\n');
|
|
2168
2175
|
}
|
|
@@ -2846,13 +2853,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2846
2853
|
const permissionMerge = mergeManagedPermissions();
|
|
2847
2854
|
summary.managedPermissions = permissionMerge;
|
|
2848
2855
|
if (permissionMerge.removedCount > 0) {
|
|
2849
|
-
console.log(` Permissions: ${permissionMerge.removedCount} retired managed
|
|
2856
|
+
console.log(` Permissions: ${permissionMerge.removedCount} retired managed rule(s) removed`);
|
|
2850
2857
|
}
|
|
2851
|
-
|
|
2858
|
+
const managedRuleCount = countManagedPermissions(permissionMerge.managedPermissions);
|
|
2859
|
+
if (managedRuleCount > 0) {
|
|
2852
2860
|
console.log(
|
|
2853
|
-
` Permissions: ${permissionMerge.addedCount} managed
|
|
2861
|
+
` Permissions: ${permissionMerge.addedCount} managed rule(s) added, `
|
|
2854
2862
|
+ `${permissionMerge.alreadyPresentCount} already present `
|
|
2855
|
-
+ `(${
|
|
2863
|
+
+ `(${managedRuleCount} package-owned)`,
|
|
2856
2864
|
);
|
|
2857
2865
|
}
|
|
2858
2866
|
|
|
@@ -2918,14 +2926,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
|
|
|
2918
2926
|
const manifestFiles = didPruneFinish
|
|
2919
2927
|
? manifestFilesWithFailedPrunes(allManagedInstalledFiles, failedPrunePaths)
|
|
2920
2928
|
: unionOnComparisonKey(priorManifestFiles || [], allManagedInstalledFiles);
|
|
2921
|
-
|
|
2922
|
-
writeManifest(
|
|
2923
|
-
manifestFiles,
|
|
2924
|
-
manifestSkillNames,
|
|
2925
|
-
managedPermissionDenyEntries.length > 0
|
|
2926
|
-
? { deny: managedPermissionDenyEntries }
|
|
2927
|
-
: null,
|
|
2928
|
-
);
|
|
2929
|
+
writeManifest(manifestFiles, manifestSkillNames, summary.managedPermissions.managedPermissions);
|
|
2929
2930
|
syncWrittenPaths([...allInstalledFiles, MANIFEST_FILE, plan.settingsPath, ...publishedPointerPaths]);
|
|
2930
2931
|
throwIfFault(FAULT_PHASES.AFTER_MANIFEST_WRITE);
|
|
2931
2932
|
console.log(`\nInstalled ${PACKAGE_NAME}:`);
|
|
@@ -3122,15 +3123,15 @@ function executeUninstallPlan(plan, helpers = {}) {
|
|
|
3122
3123
|
console.log(` Session continuity: ${removedContinuityCount} hook registration(s) removed from settings.json`);
|
|
3123
3124
|
}
|
|
3124
3125
|
}
|
|
3125
|
-
const
|
|
3126
|
-
? plan.
|
|
3127
|
-
:
|
|
3128
|
-
if (
|
|
3129
|
-
const pruneOutcome = pruneManagedPermissionsFromSettings(settings,
|
|
3126
|
+
const managedPermissionsFromPlan = countManagedPermissions(plan.managedPermissions) > 0
|
|
3127
|
+
? plan.managedPermissions
|
|
3128
|
+
: loadPackageManagedPermissions();
|
|
3129
|
+
if (countManagedPermissions(managedPermissionsFromPlan) > 0) {
|
|
3130
|
+
const pruneOutcome = pruneManagedPermissionsFromSettings(settings, managedPermissionsFromPlan);
|
|
3130
3131
|
if (pruneOutcome.removedCount > 0) {
|
|
3131
3132
|
didSettingsChange = true;
|
|
3132
3133
|
console.log(
|
|
3133
|
-
` Permission entries removed from settings.json: ${pruneOutcome.removedCount} managed
|
|
3134
|
+
` Permission entries removed from settings.json: ${pruneOutcome.removedCount} managed rule(s)`,
|
|
3134
3135
|
);
|
|
3135
3136
|
}
|
|
3136
3137
|
}
|
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
managedPermissionsFromPackageSettings,
|
|
21
21
|
mergeManagedPermissionsIntoSettings,
|
|
22
22
|
pruneManagedPermissionsFromSettings,
|
|
23
23
|
} from './merge_managed_permissions.mjs';
|
|
@@ -27,7 +27,10 @@ const PACKAGE_SETTINGS_PATH = join(PACKAGE_ROOT, 'settings.json');
|
|
|
27
27
|
const INSTALL_ENTRY = join(PACKAGE_ROOT, 'bin', 'install.mjs');
|
|
28
28
|
|
|
29
29
|
const EXPECTED_DENY_ENTRIES = [];
|
|
30
|
+
const EXPECTED_ALLOW_ENTRIES = ['WebFetch(domain:docs.github.com)'];
|
|
30
31
|
const SAMPLE_MANAGED_DENY_ENTRIES = ['Edit($HOME/.claude/managed-test/**)'];
|
|
32
|
+
const SAMPLE_MANAGED_PERMISSIONS = { allow: [], deny: SAMPLE_MANAGED_DENY_ENTRIES };
|
|
33
|
+
const SAMPLE_MANAGED_ALLOW_PERMISSIONS = { allow: ['WebFetch(domain:example.com)'], deny: [] };
|
|
31
34
|
const LEGACY_MANAGED_DENY_ENTRIES = [
|
|
32
35
|
'Edit($HOME/.claude/verification/**)',
|
|
33
36
|
'Write($HOME/.claude/code-review-stamps/**)',
|
|
@@ -36,9 +39,9 @@ const LEGACY_MANAGED_DENY_ENTRIES = [
|
|
|
36
39
|
];
|
|
37
40
|
const USER_OWNED_DENY_ENTRY = 'Bash(rm -rf /)';
|
|
38
41
|
|
|
39
|
-
function
|
|
42
|
+
function packageManagedPermissions() {
|
|
40
43
|
const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
|
|
41
|
-
return
|
|
44
|
+
return managedPermissionsFromPackageSettings(packageSettings);
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
function runInstallerInSandbox(sandboxHome, installerArguments = []) {
|
|
@@ -57,14 +60,16 @@ function runInstallerInSandbox(sandboxHome, installerArguments = []) {
|
|
|
57
60
|
});
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
test('package settings.json publishes no managed deny entries', () => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
test('package settings.json publishes the GitHub docs fetch allow and no managed deny entries', () => {
|
|
64
|
+
assert.deepEqual(packageManagedPermissions(), {
|
|
65
|
+
allow: EXPECTED_ALLOW_ENTRIES,
|
|
66
|
+
deny: EXPECTED_DENY_ENTRIES,
|
|
67
|
+
});
|
|
63
68
|
});
|
|
64
69
|
|
|
65
70
|
test('an array-valued permissions field is replaced so managed denies survive a round trip', () => {
|
|
66
71
|
const targetSettings = { permissions: [] };
|
|
67
|
-
const result = mergeManagedPermissionsIntoSettings(targetSettings,
|
|
72
|
+
const result = mergeManagedPermissionsIntoSettings(targetSettings, SAMPLE_MANAGED_PERMISSIONS);
|
|
68
73
|
assert.equal(result.addedCount, SAMPLE_MANAGED_DENY_ENTRIES.length);
|
|
69
74
|
assert.equal(Array.isArray(targetSettings.permissions), false);
|
|
70
75
|
const roundTripped = JSON.parse(JSON.stringify(targetSettings));
|
|
@@ -98,7 +103,7 @@ test('mergeManagedPermissionsIntoSettings adds each managed deny exactly once',
|
|
|
98
103
|
deny: ['Bash(rm -rf /)'],
|
|
99
104
|
},
|
|
100
105
|
};
|
|
101
|
-
const first = mergeManagedPermissionsIntoSettings(target,
|
|
106
|
+
const first = mergeManagedPermissionsIntoSettings(target, SAMPLE_MANAGED_PERMISSIONS);
|
|
102
107
|
assert.equal(first.addedCount, SAMPLE_MANAGED_DENY_ENTRIES.length);
|
|
103
108
|
assert.equal(first.alreadyPresentCount, 0);
|
|
104
109
|
const managedDenyCount = target.permissions.deny.filter((each) => (
|
|
@@ -108,7 +113,7 @@ test('mergeManagedPermissionsIntoSettings adds each managed deny exactly once',
|
|
|
108
113
|
assert.ok(target.permissions.deny.includes('Bash(rm -rf /)'));
|
|
109
114
|
assert.deepEqual(target.permissions.allow, ['Bash(git status)']);
|
|
110
115
|
|
|
111
|
-
const second = mergeManagedPermissionsIntoSettings(target,
|
|
116
|
+
const second = mergeManagedPermissionsIntoSettings(target, SAMPLE_MANAGED_PERMISSIONS);
|
|
112
117
|
assert.equal(second.addedCount, 0);
|
|
113
118
|
assert.equal(second.alreadyPresentCount, SAMPLE_MANAGED_DENY_ENTRIES.length);
|
|
114
119
|
assert.equal(
|
|
@@ -128,13 +133,27 @@ test('pruneManagedPermissionsFromSettings removes only package-owned deny entrie
|
|
|
128
133
|
],
|
|
129
134
|
},
|
|
130
135
|
};
|
|
131
|
-
const outcome = pruneManagedPermissionsFromSettings(target,
|
|
136
|
+
const outcome = pruneManagedPermissionsFromSettings(target, SAMPLE_MANAGED_PERMISSIONS);
|
|
132
137
|
assert.equal(outcome.removedCount, SAMPLE_MANAGED_DENY_ENTRIES.length);
|
|
133
138
|
assert.deepEqual(target.permissions.deny, ['Bash(rm -rf /)']);
|
|
134
139
|
assert.deepEqual(target.permissions.allow, ['Bash(git status)']);
|
|
135
140
|
assert.deepEqual(target.permissions.ask, ['Edit(./**)']);
|
|
136
141
|
});
|
|
137
142
|
|
|
143
|
+
test('mergeManagedPermissionsIntoSettings adds a managed allow once and prune removes only it', () => {
|
|
144
|
+
const target = { permissions: { allow: ['Bash(git status)'] } };
|
|
145
|
+
const first = mergeManagedPermissionsIntoSettings(target, SAMPLE_MANAGED_ALLOW_PERMISSIONS);
|
|
146
|
+
assert.equal(first.addedCount, 1);
|
|
147
|
+
const second = mergeManagedPermissionsIntoSettings(target, SAMPLE_MANAGED_ALLOW_PERMISSIONS);
|
|
148
|
+
assert.equal(second.addedCount, 0);
|
|
149
|
+
assert.deepEqual(target.permissions.allow, ['Bash(git status)', 'WebFetch(domain:example.com)']);
|
|
150
|
+
assert.equal(Object.hasOwn(target.permissions, 'deny'), false);
|
|
151
|
+
|
|
152
|
+
const outcome = pruneManagedPermissionsFromSettings(target, SAMPLE_MANAGED_ALLOW_PERMISSIONS);
|
|
153
|
+
assert.equal(outcome.removedCount, 1);
|
|
154
|
+
assert.deepEqual(target.permissions.allow, ['Bash(git status)']);
|
|
155
|
+
});
|
|
156
|
+
|
|
138
157
|
test('sandbox install keeps default managed denies empty on repeat', () => {
|
|
139
158
|
const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
|
|
140
159
|
try {
|
|
@@ -146,16 +165,19 @@ test('sandbox install keeps default managed denies empty on repeat', () => {
|
|
|
146
165
|
const firstSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
147
166
|
const firstDeny = firstSettings.permissions?.deny ?? [];
|
|
148
167
|
assert.deepEqual(firstDeny, EXPECTED_DENY_ENTRIES);
|
|
168
|
+
assert.deepEqual(firstSettings.permissions?.allow, EXPECTED_ALLOW_ENTRIES);
|
|
149
169
|
|
|
150
170
|
const manifestPath = join(sandboxHome, '.claude', '.claude-dev-env-manifest.json');
|
|
151
171
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
152
172
|
assert.deepEqual(manifest.managedPermissions?.deny ?? [], EXPECTED_DENY_ENTRIES);
|
|
173
|
+
assert.deepEqual(manifest.managedPermissions?.allow, EXPECTED_ALLOW_ENTRIES);
|
|
153
174
|
|
|
154
175
|
const secondInstall = runInstallerInSandbox(sandboxHome, []);
|
|
155
176
|
assert.equal(secondInstall.status, 0, secondInstall.stderr || secondInstall.stdout);
|
|
156
177
|
const secondSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
157
178
|
const secondDeny = secondSettings.permissions?.deny ?? [];
|
|
158
179
|
assert.deepEqual(secondDeny, EXPECTED_DENY_ENTRIES);
|
|
180
|
+
assert.deepEqual(secondSettings.permissions?.allow, EXPECTED_ALLOW_ENTRIES);
|
|
159
181
|
} finally {
|
|
160
182
|
rmSync(sandboxHome, { recursive: true, force: true });
|
|
161
183
|
}
|
|
@@ -186,11 +208,11 @@ test('a normal upgrade retires manifest-owned denies and preserves user entries'
|
|
|
186
208
|
|
|
187
209
|
const upgradedSettings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
188
210
|
assert.deepEqual(upgradedSettings.permissions?.deny, [USER_OWNED_DENY_ENTRY]);
|
|
189
|
-
assert.deepEqual(upgradedSettings.permissions?.allow, ['Bash(git status)']);
|
|
211
|
+
assert.deepEqual(upgradedSettings.permissions?.allow, ['Bash(git status)', ...EXPECTED_ALLOW_ENTRIES]);
|
|
190
212
|
assert.deepEqual(upgradedSettings.permissions?.ask, ['Edit(./**)']);
|
|
191
213
|
|
|
192
214
|
const upgradedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
193
|
-
assert.
|
|
215
|
+
assert.deepEqual(upgradedManifest.managedPermissions, { allow: EXPECTED_ALLOW_ENTRIES });
|
|
194
216
|
} finally {
|
|
195
217
|
rmSync(sandboxHome, { recursive: true, force: true });
|
|
196
218
|
}
|
|
@@ -219,6 +241,9 @@ test('sandbox uninstall removes only package-owned permission entries and keeps
|
|
|
219
241
|
for (const eachEntry of EXPECTED_DENY_ENTRIES) {
|
|
220
242
|
assert.ok(!denyAfter.includes(eachEntry), `managed deny still present: ${eachEntry}`);
|
|
221
243
|
}
|
|
244
|
+
for (const eachEntry of EXPECTED_ALLOW_ENTRIES) {
|
|
245
|
+
assert.ok(!(after.permissions?.allow ?? []).includes(eachEntry), `managed allow still present: ${eachEntry}`);
|
|
246
|
+
}
|
|
222
247
|
assert.ok(denyAfter.includes('Bash(rm -rf /)'), 'user deny preserved');
|
|
223
248
|
assert.ok((after.permissions?.allow ?? []).includes('Bash(git status)'), 'user allow preserved');
|
|
224
249
|
assert.ok((after.permissions?.ask ?? []).includes('Edit(./**)'), 'user ask preserved');
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Merge package-owned permission defaults into a settings object.
|
|
3
3
|
*
|
|
4
|
-
* Ownership is the stable
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* allow/ask/deny entry
|
|
4
|
+
* Ownership is the stable rule string published in the package settings.json
|
|
5
|
+
* `permissions.allow` and `permissions.deny` lists. Install ensures each
|
|
6
|
+
* package rule appears once in its list. Uninstall removes only those
|
|
7
|
+
* package-owned rule strings and leaves every other allow/ask/deny entry
|
|
8
|
+
* unchanged.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -23,35 +24,91 @@
|
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* allow: string[],
|
|
29
|
+
* deny: string[],
|
|
30
|
+
* }} ManagedPermissions
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const MANAGED_PERMISSION_LIST_NAMES = Object.freeze(['allow', 'deny']);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build an empty managed-permissions record.
|
|
37
|
+
*
|
|
38
|
+
* @returns {ManagedPermissions}
|
|
39
|
+
*/
|
|
40
|
+
export function emptyManagedPermissions() {
|
|
41
|
+
return { allow: [], deny: [] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the managed allow and deny lists from a package settings source object
|
|
46
|
+
* or a manifest record of the same shape.
|
|
27
47
|
*
|
|
28
48
|
* @param {SettingsObject | null | undefined} packageSettings
|
|
29
|
-
* @returns {
|
|
49
|
+
* @returns {ManagedPermissions}
|
|
30
50
|
*/
|
|
31
|
-
export function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
export function managedPermissionsFromPackageSettings(packageSettings) {
|
|
52
|
+
const managedPermissions = emptyManagedPermissions();
|
|
53
|
+
for (const eachListName of MANAGED_PERMISSION_LIST_NAMES) {
|
|
54
|
+
const sourceList = packageSettings?.permissions?.[eachListName];
|
|
55
|
+
if (!Array.isArray(sourceList)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
managedPermissions[eachListName] = sourceList.filter(
|
|
59
|
+
(eachEntry) => typeof eachEntry === 'string' && eachEntry.trim().length > 0,
|
|
60
|
+
);
|
|
35
61
|
}
|
|
36
|
-
return
|
|
62
|
+
return managedPermissions;
|
|
37
63
|
}
|
|
38
64
|
|
|
39
65
|
/**
|
|
40
|
-
*
|
|
66
|
+
* Count the rules a managed-permissions record holds across every list.
|
|
41
67
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
68
|
+
* @param {ManagedPermissions} managedPermissions
|
|
69
|
+
* @returns {number}
|
|
70
|
+
*/
|
|
71
|
+
export function countManagedPermissions(managedPermissions) {
|
|
72
|
+
return MANAGED_PERMISSION_LIST_NAMES.reduce(
|
|
73
|
+
(runningCount, eachListName) => runningCount + managedPermissions[eachListName].length,
|
|
74
|
+
0,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List the rules a prior record owned that the current record no longer owns.
|
|
80
|
+
*
|
|
81
|
+
* @param {ManagedPermissions} priorPermissions
|
|
82
|
+
* @param {ManagedPermissions} currentPermissions
|
|
83
|
+
* @returns {ManagedPermissions}
|
|
84
|
+
*/
|
|
85
|
+
export function retiredManagedPermissions(priorPermissions, currentPermissions) {
|
|
86
|
+
const retiredPermissions = emptyManagedPermissions();
|
|
87
|
+
for (const eachListName of MANAGED_PERMISSION_LIST_NAMES) {
|
|
88
|
+
const currentSet = new Set(currentPermissions[eachListName]);
|
|
89
|
+
retiredPermissions[eachListName] = priorPermissions[eachListName].filter(
|
|
90
|
+
(eachEntry) => !currentSet.has(eachEntry),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return retiredPermissions;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merge package-owned allow and deny rules into target settings in place.
|
|
98
|
+
*
|
|
99
|
+
* Existing user entries stay as written. Package rules already present are
|
|
100
|
+
* not duplicated.
|
|
44
101
|
*
|
|
45
102
|
* @param {SettingsObject} targetSettings
|
|
46
|
-
* @param {
|
|
47
|
-
* @returns {{addedCount: number, alreadyPresentCount: number,
|
|
103
|
+
* @param {ManagedPermissions} managedPermissions
|
|
104
|
+
* @returns {{addedCount: number, alreadyPresentCount: number, managedPermissions: ManagedPermissions}}
|
|
48
105
|
*/
|
|
49
|
-
export function mergeManagedPermissionsIntoSettings(targetSettings,
|
|
106
|
+
export function mergeManagedPermissionsIntoSettings(targetSettings, managedPermissions) {
|
|
50
107
|
if (!targetSettings || typeof targetSettings !== 'object' || Array.isArray(targetSettings)) {
|
|
51
108
|
throw new TypeError('targetSettings must be a plain object');
|
|
52
109
|
}
|
|
53
|
-
if (!
|
|
54
|
-
throw new TypeError('
|
|
110
|
+
if (!managedPermissions || typeof managedPermissions !== 'object') {
|
|
111
|
+
throw new TypeError('managedPermissions must be an object of allow and deny lists');
|
|
55
112
|
}
|
|
56
113
|
|
|
57
114
|
if (
|
|
@@ -62,65 +119,78 @@ export function mergeManagedPermissionsIntoSettings(targetSettings, managedDenyE
|
|
|
62
119
|
targetSettings.permissions = {};
|
|
63
120
|
}
|
|
64
121
|
const permissions = targetSettings.permissions;
|
|
65
|
-
if (!Array.isArray(permissions.deny)) {
|
|
66
|
-
permissions.deny = [];
|
|
67
|
-
}
|
|
68
122
|
|
|
69
123
|
let addedCount = 0;
|
|
70
124
|
let alreadyPresentCount = 0;
|
|
71
|
-
for (const
|
|
72
|
-
|
|
125
|
+
for (const eachListName of MANAGED_PERMISSION_LIST_NAMES) {
|
|
126
|
+
const managedEntries = managedPermissions[eachListName] ?? [];
|
|
127
|
+
if (managedEntries.length === 0) {
|
|
73
128
|
continue;
|
|
74
129
|
}
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
130
|
+
if (!Array.isArray(permissions[eachListName])) {
|
|
131
|
+
permissions[eachListName] = [];
|
|
132
|
+
}
|
|
133
|
+
for (const eachEntry of managedEntries) {
|
|
134
|
+
if (typeof eachEntry !== 'string' || eachEntry.length === 0) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (permissions[eachListName].includes(eachEntry)) {
|
|
138
|
+
alreadyPresentCount += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
permissions[eachListName].push(eachEntry);
|
|
142
|
+
addedCount += 1;
|
|
78
143
|
}
|
|
79
|
-
permissions.deny.push(eachEntry);
|
|
80
|
-
addedCount += 1;
|
|
81
144
|
}
|
|
82
145
|
|
|
83
146
|
return {
|
|
84
147
|
addedCount,
|
|
85
148
|
alreadyPresentCount,
|
|
86
|
-
|
|
149
|
+
managedPermissions: {
|
|
150
|
+
allow: [...(managedPermissions.allow ?? [])],
|
|
151
|
+
deny: [...(managedPermissions.deny ?? [])],
|
|
152
|
+
},
|
|
87
153
|
};
|
|
88
154
|
}
|
|
89
155
|
|
|
90
156
|
/**
|
|
91
|
-
* Remove package-owned deny
|
|
157
|
+
* Remove package-owned allow and deny rules from target settings in place.
|
|
92
158
|
*
|
|
93
|
-
* User allow, ask, and
|
|
94
|
-
* lists are dropped so uninstall leaves a clean settings object.
|
|
159
|
+
* User allow, ask, and deny entries the package does not own remain. Empty
|
|
160
|
+
* permission lists are dropped so uninstall leaves a clean settings object.
|
|
95
161
|
*
|
|
96
162
|
* @param {SettingsObject} targetSettings
|
|
97
|
-
* @param {
|
|
163
|
+
* @param {ManagedPermissions} managedPermissions
|
|
98
164
|
* @returns {{removedCount: number}}
|
|
99
165
|
*/
|
|
100
|
-
export function pruneManagedPermissionsFromSettings(targetSettings,
|
|
166
|
+
export function pruneManagedPermissionsFromSettings(targetSettings, managedPermissions) {
|
|
101
167
|
if (!targetSettings?.permissions || typeof targetSettings.permissions !== 'object') {
|
|
102
168
|
return { removedCount: 0 };
|
|
103
169
|
}
|
|
104
170
|
const permissions = targetSettings.permissions;
|
|
105
|
-
if (!Array.isArray(permissions.deny) || permissions.deny.length === 0) {
|
|
106
|
-
return { removedCount: 0 };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const managedSet = new Set(
|
|
110
|
-
managedDenyEntries.filter((eachEntry) => typeof eachEntry === 'string'),
|
|
111
|
-
);
|
|
112
|
-
const denyCountBefore = permissions.deny.length;
|
|
113
|
-
permissions.deny = permissions.deny.filter((eachEntry) => !managedSet.has(eachEntry));
|
|
114
|
-
const removedCount = denyCountBefore - permissions.deny.length;
|
|
115
171
|
|
|
116
|
-
|
|
117
|
-
|
|
172
|
+
let removedCount = 0;
|
|
173
|
+
for (const eachListName of MANAGED_PERMISSION_LIST_NAMES) {
|
|
174
|
+
if (!Array.isArray(permissions[eachListName])) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const managedSet = new Set(
|
|
178
|
+
(managedPermissions?.[eachListName] ?? []).filter((eachEntry) => typeof eachEntry === 'string'),
|
|
179
|
+
);
|
|
180
|
+
const countBefore = permissions[eachListName].length;
|
|
181
|
+
permissions[eachListName] = permissions[eachListName].filter(
|
|
182
|
+
(eachEntry) => !managedSet.has(eachEntry),
|
|
183
|
+
);
|
|
184
|
+
removedCount += countBefore - permissions[eachListName].length;
|
|
118
185
|
}
|
|
119
|
-
if (
|
|
120
|
-
|
|
186
|
+
if (removedCount === 0) {
|
|
187
|
+
return { removedCount };
|
|
121
188
|
}
|
|
122
|
-
|
|
123
|
-
|
|
189
|
+
|
|
190
|
+
for (const eachListName of ['allow', 'ask', 'deny']) {
|
|
191
|
+
if (Array.isArray(permissions[eachListName]) && permissions[eachListName].length === 0) {
|
|
192
|
+
delete permissions[eachListName];
|
|
193
|
+
}
|
|
124
194
|
}
|
|
125
195
|
if (Object.keys(permissions).length === 0) {
|
|
126
196
|
delete targetSettings.permissions;
|
|
@@ -25,7 +25,9 @@ WORKING_STYLE_PROMPT = (
|
|
|
25
25
|
"Deliver the requested work at its intended scope. Make routine judgment calls "
|
|
26
26
|
"yourself. Ask for direction when different interpretations would produce "
|
|
27
27
|
"materially different work. When a request seems mistaken or a better approach "
|
|
28
|
-
"exists, state the concern briefly and continue with the requested task.
|
|
28
|
+
"exists, state the concern briefly and continue with the requested task. "
|
|
29
|
+
"A request to remove something is complete once it is gone. Put nothing in its "
|
|
30
|
+
"place, and pass a replacement idea to the requester as a question. Finish "
|
|
29
31
|
"the complete task and keep actions within the requested scope. "
|
|
30
32
|
"When a request has multiple reasonable interpretations, state your understanding "
|
|
31
33
|
"and the assumptions that shape the work. Ask one focused clarification question "
|
|
@@ -43,6 +43,8 @@ class TestWorkingStylePrompt:
|
|
|
43
43
|
prompt_text = emitted["hookSpecificOutput"]["additionalContext"]
|
|
44
44
|
assert "Document each task in a location that remains easy to find later." in prompt_text
|
|
45
45
|
assert "Deliver the requested work at its intended scope." in prompt_text
|
|
46
|
+
assert "A request to remove something is complete once it is gone." in prompt_text
|
|
47
|
+
assert "pass a replacement idea to the requester as a question." in prompt_text
|
|
46
48
|
assert "Use ELI5 for beginner framing, large visuals, minimal text" in prompt_text
|
|
47
49
|
assert "one stable self-contained HTML artifact" in prompt_text
|
|
48
50
|
assert "update-in-place continuity, and sharing" in prompt_text
|
package/package.json
CHANGED
|
@@ -35,6 +35,8 @@ The driving account is the one that opened the pull request. Where the agent com
|
|
|
35
35
|
|
|
36
36
|
A private repository that installs this package runs the same command from its own workflow, against the revision of this package that its workflow pins.
|
|
37
37
|
|
|
38
|
+
A repository that merges through a merge queue also runs the check on `merge_group` and lists `Review closure` as a required check. The queue ref `gh-readonly-queue/<base>/pr-<number>-<sha>` names the pull request number the command takes. A finding posted while an entry waits in the queue then fails the queue build. Without that trigger, the entry merges on the verdict it carried when it joined the queue.
|
|
39
|
+
|
|
38
40
|
## Sibling rules
|
|
39
41
|
|
|
40
42
|
| Rule | Role |
|