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.
@@ -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
@@ -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
- managedPermissionDenyEntries: Object.freeze([]),
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 rawManagedDeny = isManifestObject
378
- ? manifest[MANIFEST_MANAGED_PERMISSIONS_KEY]?.deny
379
- : null;
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
- managedPermissionDenyEntries: Object.freeze(managedPermissionDenyEntries),
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
- managedDenyEntriesFromPackageSettings,
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 && Array.isArray(managedPermissions.deny) && managedPermissions.deny.length > 0) {
2089
- manifest[MANIFEST_MANAGED_PERMISSIONS_KEY] = {
2090
- deny: [...managedPermissions.deny],
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 entries from the package settings.json source.
2105
+ * Load package-owned allow and deny rules from the package settings.json source.
2098
2106
  *
2099
- * @returns {string[]}
2107
+ * @returns {import('./merge_managed_permissions.mjs').ManagedPermissions}
2100
2108
  */
2101
- function loadPackageManagedDenyEntries() {
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 managedDenyEntriesFromPackageSettings(packageSettings);
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 entries an earlier manifest records as package-managed.
2126
+ * Load the allow and deny rules an earlier manifest records as package-managed.
2119
2127
  *
2120
- * @returns {string[]}
2128
+ * @returns {import('./merge_managed_permissions.mjs').ManagedPermissions}
2121
2129
  */
2122
- function loadPriorManagedDenyEntries() {
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 managedDenyEntriesFromPackageSettings({
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, managedDenyEntries: string[]}}
2145
+ * @returns {{addedCount: number, alreadyPresentCount: number, removedCount: number, managedPermissions: import('./merge_managed_permissions.mjs').ManagedPermissions}}
2138
2146
  */
2139
2147
  function mergeManagedPermissions() {
2140
- const allCurrentManagedDenyEntries = loadPackageManagedDenyEntries();
2141
- const allPriorManagedDenyEntries = loadPriorManagedDenyEntries();
2142
- const allCurrentManagedDenyEntrySet = new Set(allCurrentManagedDenyEntries);
2143
- const allRetiredManagedDenyEntries = allPriorManagedDenyEntries.filter(
2144
- eachEntry => !allCurrentManagedDenyEntrySet.has(eachEntry),
2148
+ const currentManagedPermissions = loadPackageManagedPermissions();
2149
+ const retiredPermissions = retiredManagedPermissions(
2150
+ loadPriorManagedPermissions(),
2151
+ currentManagedPermissions,
2145
2152
  );
2146
- const hasCurrentManagedDenyEntries = allCurrentManagedDenyEntries.length > 0;
2147
- const hasRetiredManagedDenyEntries = allRetiredManagedDenyEntries.length > 0;
2148
- if (!hasCurrentManagedDenyEntries && !hasRetiredManagedDenyEntries) {
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
- managedDenyEntries: [],
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, allRetiredManagedDenyEntries);
2160
- const mergeOutcome = hasCurrentManagedDenyEntries
2161
- ? mergeManagedPermissionsIntoSettings(settings, allCurrentManagedDenyEntries)
2162
- : { addedCount: 0, alreadyPresentCount: 0, managedDenyEntries: [] };
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 && hasCurrentManagedDenyEntries);
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 deny(s) removed`);
2856
+ console.log(` Permissions: ${permissionMerge.removedCount} retired managed rule(s) removed`);
2850
2857
  }
2851
- if (permissionMerge.managedDenyEntries.length > 0) {
2858
+ const managedRuleCount = countManagedPermissions(permissionMerge.managedPermissions);
2859
+ if (managedRuleCount > 0) {
2852
2860
  console.log(
2853
- ` Permissions: ${permissionMerge.addedCount} managed deny(s) added, `
2861
+ ` Permissions: ${permissionMerge.addedCount} managed rule(s) added, `
2854
2862
  + `${permissionMerge.alreadyPresentCount} already present `
2855
- + `(${permissionMerge.managedDenyEntries.length} package-owned)`,
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
- const managedPermissionDenyEntries = summary.managedPermissions.managedDenyEntries;
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 managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
3126
- ? plan.managedPermissionDenyEntries
3127
- : loadPackageManagedDenyEntries();
3128
- if (managedDenyFromPlan.length > 0) {
3129
- const pruneOutcome = pruneManagedPermissionsFromSettings(settings, managedDenyFromPlan);
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 deny(s)`,
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
- managedDenyEntriesFromPackageSettings,
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 packageDenyEntries() {
42
+ function packageManagedPermissions() {
40
43
  const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
41
- return managedDenyEntriesFromPackageSettings(packageSettings);
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
- const denyEntries = packageDenyEntries();
62
- assert.deepEqual(denyEntries, EXPECTED_DENY_ENTRIES);
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, SAMPLE_MANAGED_DENY_ENTRIES);
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, SAMPLE_MANAGED_DENY_ENTRIES);
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, SAMPLE_MANAGED_DENY_ENTRIES);
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, SAMPLE_MANAGED_DENY_ENTRIES);
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.equal(Object.hasOwn(upgradedManifest, 'managedPermissions'), false);
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 deny-entry string published in the package
5
- * settings.json. Install ensures each package deny appears once. Uninstall
6
- * removes only those package-owned identity strings and leaves every other
7
- * allow/ask/deny entry unchanged.
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
- * Read the managed deny list from a package settings source object.
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 {string[]}
49
+ * @returns {ManagedPermissions}
30
50
  */
31
- export function managedDenyEntriesFromPackageSettings(packageSettings) {
32
- const denyList = packageSettings?.permissions?.deny;
33
- if (!Array.isArray(denyList)) {
34
- return [];
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 denyList.filter((eachEntry) => typeof eachEntry === 'string' && eachEntry.length > 0);
62
+ return managedPermissions;
37
63
  }
38
64
 
39
65
  /**
40
- * Merge package-owned deny entries into target settings in place.
66
+ * Count the rules a managed-permissions record holds across every list.
41
67
  *
42
- * Existing allow/ask entries and non-package deny entries stay as written.
43
- * Package deny strings already present are not duplicated.
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 {string[]} managedDenyEntries
47
- * @returns {{addedCount: number, alreadyPresentCount: number, managedDenyEntries: string[]}}
103
+ * @param {ManagedPermissions} managedPermissions
104
+ * @returns {{addedCount: number, alreadyPresentCount: number, managedPermissions: ManagedPermissions}}
48
105
  */
49
- export function mergeManagedPermissionsIntoSettings(targetSettings, managedDenyEntries) {
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 (!Array.isArray(managedDenyEntries)) {
54
- throw new TypeError('managedDenyEntries must be an array');
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 eachEntry of managedDenyEntries) {
72
- if (typeof eachEntry !== 'string' || eachEntry.length === 0) {
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 (permissions.deny.includes(eachEntry)) {
76
- alreadyPresentCount += 1;
77
- continue;
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
- managedDenyEntries: [...managedDenyEntries],
149
+ managedPermissions: {
150
+ allow: [...(managedPermissions.allow ?? [])],
151
+ deny: [...(managedPermissions.deny ?? [])],
152
+ },
87
153
  };
88
154
  }
89
155
 
90
156
  /**
91
- * Remove package-owned deny entries from target settings in place.
157
+ * Remove package-owned allow and deny rules from target settings in place.
92
158
  *
93
- * User allow, ask, and non-package deny entries remain. Empty permission
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 {string[]} managedDenyEntries
163
+ * @param {ManagedPermissions} managedPermissions
98
164
  * @returns {{removedCount: number}}
99
165
  */
100
- export function pruneManagedPermissionsFromSettings(targetSettings, managedDenyEntries) {
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
- if (permissions.deny.length === 0) {
117
- delete permissions.deny;
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 (Array.isArray(permissions.allow) && permissions.allow.length === 0) {
120
- delete permissions.allow;
186
+ if (removedCount === 0) {
187
+ return { removedCount };
121
188
  }
122
- if (Array.isArray(permissions.ask) && permissions.ask.length === 0) {
123
- delete permissions.ask;
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. Finish "
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.16.0",
3
+ "version": "8.17.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 |
package/settings.json CHANGED
@@ -1 +1,7 @@
1
- {}
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:docs.github.com)"
5
+ ]
6
+ }
7
+ }