claude-dev-env 8.79.0 → 8.80.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.
Files changed (57) hide show
  1. package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
  2. package/bin/install.settings-defaults.test.mjs +27 -2
  3. package/bin/merge_settings_defaults.mjs +39 -21
  4. package/bin/merge_settings_defaults.test.mjs +42 -0
  5. package/docs/account-broker.md +1 -1
  6. package/docs/codex-accounts.md +6 -5
  7. package/hooks/blocking/cloud_graphql_gate.py +213 -0
  8. package/hooks/blocking/headless_claude_broker_gate.py +1 -8
  9. package/hooks/blocking/merge_review_gate.py +65 -3
  10. package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
  11. package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
  12. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
  13. package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
  14. package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
  15. package/hooks/blocking/test_merge_review_gate.py +63 -0
  16. package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
  17. package/hooks/conftest.py +1 -1
  18. package/hooks/features/bash-dispatchers.md +3 -0
  19. package/hooks/features/conduct-gates.md +2 -2
  20. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
  21. package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
  22. package/hooks/hooks_constants/cloud_session.py +17 -0
  23. package/hooks/hooks_constants/cloud_session_constants.py +11 -0
  24. package/hooks/hooks_constants/gh_command_words.py +52 -0
  25. package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
  26. package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
  27. package/hooks/hooks_constants/hook_block_logger.py +21 -3
  28. package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
  29. package/hooks/hooks_constants/mod_handoff.py +2 -3
  30. package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
  31. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
  32. package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
  33. package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
  34. package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
  35. package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
  36. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
  37. package/hooks/hooks_constants/test_cloud_session.py +27 -0
  38. package/hooks/hooks_constants/test_gh_command_words.py +45 -0
  39. package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
  40. package/hooks/hooks_constants/test_mod_handoff.py +3 -2
  41. package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
  42. package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
  43. package/hooks/test_conftest.py +1 -1
  44. package/package.json +1 -1
  45. package/scripts/account_broker_support.py +10 -11
  46. package/scripts/codex_account_meters.py +30 -43
  47. package/scripts/dev_env_scripts_constants/account_broker_constants.py +34 -25
  48. package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
  49. package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
  50. package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +42 -0
  51. package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
  52. package/scripts/merge_review_receipt.py +56 -12
  53. package/scripts/test_account_broker.py +12 -0
  54. package/scripts/test_account_broker_support.py +32 -6
  55. package/scripts/test_codex_account_meters.py +26 -14
  56. package/scripts/test_merge_review_receipt.py +86 -0
  57. package/settings.json +3 -0
@@ -4,8 +4,8 @@ A pull request the agent made merges only after a code review of its head comes
4
4
 
5
5
  1. Review the head with the command `CLAUDE_MERGE_REVIEW_COMMAND` names. Where it is unset, run `/code-review high --max-findings all` against the base.
6
6
  2. Record the pass: `python3 ~/.claude/scripts/merge_review_receipt.py <owner>/<name> <number> <head sha> <finding count>`.
7
- 3. Fix or answer every finding, push, and review the new head.
8
- 4. Repeat until a pass records `CLEAN`.
7
+ 3. Fix every valid finding, push, and review the new head. Answer each finding you rule out on the pull request with its file:line evidence.
8
+ 4. Repeat until a pass records `CLEAN`. A pass whose every finding is ruled out records `CLEAN` with `--ruled-out <finding count> --rulings <link to the rulings comment on this pull request>`.
9
9
  5. Merge pinned to that head: `expectedHeadSha` on the merge tool, or `--match-head-commit` on `gh pr merge`.
10
10
 
11
11
  `hooks/blocking/merge_review_gate.py` denies any other merge. In a merge-queue repository, enqueue with `gh pr merge <number> --repo <owner>/<name> --auto --match-head-commit <head sha>`; auto-merge that names no head is denied. A repository whose own CI and merge queue review and merge its pull requests is listed in `CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES`, comma separated as `owner/name`, and skips the gate.
@@ -5,6 +5,7 @@
5
5
  import { test } from 'node:test';
6
6
  import { strict as assert } from 'node:assert';
7
7
  import {
8
+ mkdirSync,
8
9
  mkdtempSync,
9
10
  rmSync,
10
11
  writeFileSync,
@@ -263,14 +264,15 @@ test('sandbox uninstall removes only package-owned permission entries and keeps
263
264
  }
264
265
  });
265
266
 
266
- test('package settings.json publishes the session agent, advisor model, auto-compact and auto mode defaults', () => {
267
+ test('package settings.json publishes the session agent, advisor model, auto-compact, auto mode and idle compaction defaults', () => {
267
268
  const packageSettings = JSON.parse(readFileSync(PACKAGE_SETTINGS_PATH, 'utf8'));
268
269
  const settingsDefaults = settingsDefaultsFromPackageSettings(packageSettings);
269
270
  assert.deepEqual(
270
271
  Object.keys(settingsDefaults).sort(),
271
- ['advisorModel', 'agent', 'autoCompactWindow', 'autoMode', 'modelSettings'],
272
+ ['advisorModel', 'agent', 'autoCompactWindow', 'autoMode', 'env', 'modelSettings'],
272
273
  );
273
274
  assert.equal(settingsDefaults.autoCompactWindow, 350000);
275
+ assert.deepEqual(settingsDefaults.env, { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000' });
274
276
  assert.deepEqual(settingsDefaults.modelSettings, {
275
277
  'claude-haiku-5-5': { autoCompactWindow: 100000 },
276
278
  'claude-haiku-4-5': { autoCompactWindow: 100000 },
@@ -301,6 +303,29 @@ test('a missing default is added and a user-set value is kept', () => {
301
303
  assert.equal(userSettings.advisorModel, 'sonnet');
302
304
  });
303
305
 
306
+ test('install adds the idle compaction variable to an env block and keeps a user value on rerun', () => {
307
+ const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
308
+ try {
309
+ const settingsPath = join(sandboxHome, '.claude', 'settings.json');
310
+ mkdirSync(join(sandboxHome, '.claude'), { recursive: true });
311
+ writeFileSync(settingsPath, JSON.stringify({ env: { EDITOR: 'vim' } }, null, 4) + '\n');
312
+ const firstRun = runInstallerInSandbox(sandboxHome);
313
+ assert.equal(firstRun.status, 0, firstRun.stderr);
314
+ assert.deepEqual(JSON.parse(readFileSync(settingsPath, 'utf8')).env, {
315
+ EDITOR: 'vim',
316
+ CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000',
317
+ });
318
+ const userChoice = JSON.parse(readFileSync(settingsPath, 'utf8'));
319
+ userChoice.env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS = '300000';
320
+ writeFileSync(settingsPath, JSON.stringify(userChoice, null, 4) + '\n');
321
+ const secondRun = runInstallerInSandbox(sandboxHome);
322
+ assert.equal(secondRun.status, 0, secondRun.stderr);
323
+ assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).env.CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS, '300000');
324
+ } finally {
325
+ rmSync(sandboxHome, { recursive: true, force: true });
326
+ }
327
+ });
328
+
304
329
  test('install adds advisorModel when missing and keeps a user choice on rerun', () => {
305
330
  const sandboxHome = mkdtempSync(join(tmpdir(), 'cde-settings-defaults-'));
306
331
  try {
@@ -2,38 +2,57 @@
2
2
  * Merge package-owned scalar setting defaults into a settings object.
3
3
  *
4
4
  * Every top-level key in the package settings.json other than the managed
5
- * lists (`permissions`) is a default. Install adds a default only when the
6
- * user settings lack that key, so a value the user already set stays as is.
5
+ * lists (`permissions`) is a default. Install adds a scalar default only when
6
+ * the user settings lack that key, so a value the user already set stays as
7
+ * is. The nested keys below merge field by field instead.
7
8
  *
8
9
  * A per-model key (`modelSettings`) merges one level deeper: install adds each
9
10
  * model entry, and each field inside an entry, that the user settings lack.
11
+ *
12
+ * A per-variable key (`env`) merges one level deep. Install adds each variable
13
+ * the user's block lacks and keeps each variable the user already set.
10
14
  */
11
15
 
12
16
  export const MANAGED_LIST_SETTING_KEYS = Object.freeze(['permissions']);
13
- export const PER_MODEL_SETTING_KEYS = Object.freeze(['modelSettings']);
14
17
 
15
18
  function isPlainObject(candidate) {
16
19
  return Boolean(candidate) && typeof candidate === 'object' && !Array.isArray(candidate);
17
20
  }
18
21
 
19
- function mergeMissingPerModelFields(targetPerModel, defaultPerModel, settingKey) {
22
+ function addMissingField(targetFields, fieldName, fieldDefault) {
23
+ if (Object.hasOwn(targetFields, fieldName)) return false;
24
+ targetFields[fieldName] = structuredClone(fieldDefault);
25
+ return true;
26
+ }
27
+
28
+ function mergeMissingFields(targetFields, defaultFields, keyPrefix) {
29
+ const addedKeys = [];
30
+ for (const [fieldName, fieldDefault] of Object.entries(defaultFields)) {
31
+ if (addMissingField(targetFields, fieldName, fieldDefault)) {
32
+ addedKeys.push(`${keyPrefix}.${fieldName}`);
33
+ }
34
+ }
35
+ return addedKeys;
36
+ }
37
+
38
+ function mergeMissingPerModelFields(targetPerModel, defaultPerModel, keyPrefix) {
20
39
  const addedKeys = [];
21
40
  for (const [modelName, defaultFields] of Object.entries(defaultPerModel)) {
22
- if (!Object.hasOwn(targetPerModel, modelName)) {
23
- targetPerModel[modelName] = { ...defaultFields };
24
- addedKeys.push(`${settingKey}.${modelName}`);
41
+ if (addMissingField(targetPerModel, modelName, defaultFields)) {
42
+ addedKeys.push(`${keyPrefix}.${modelName}`);
25
43
  continue;
26
44
  }
27
45
  if (!isPlainObject(targetPerModel[modelName])) continue;
28
- for (const [fieldName, fieldDefault] of Object.entries(defaultFields)) {
29
- if (Object.hasOwn(targetPerModel[modelName], fieldName)) continue;
30
- targetPerModel[modelName][fieldName] = fieldDefault;
31
- addedKeys.push(`${settingKey}.${modelName}.${fieldName}`);
32
- }
46
+ addedKeys.push(...mergeMissingFields(targetPerModel[modelName], defaultFields, `${keyPrefix}.${modelName}`));
33
47
  }
34
48
  return addedKeys;
35
49
  }
36
50
 
51
+ const NESTED_MERGE_BY_SETTING_KEY = new Map([
52
+ ['modelSettings', mergeMissingPerModelFields],
53
+ ['env', mergeMissingFields],
54
+ ]);
55
+
37
56
  /**
38
57
  * Read the setting defaults from a package settings source object.
39
58
  *
@@ -52,7 +71,8 @@ export function settingsDefaultsFromPackageSettings(packageSettings) {
52
71
  }
53
72
 
54
73
  /**
55
- * Add each default whose key the target settings lack.
74
+ * Add each default whose key the target settings lack, and fill the missing
75
+ * entries and fields inside an existing `modelSettings` or `env` object.
56
76
  *
57
77
  * @param {Record<string, unknown>} targetSettings Mutated in place.
58
78
  * @param {Record<string, unknown>} settingsDefaults
@@ -61,16 +81,14 @@ export function settingsDefaultsFromPackageSettings(packageSettings) {
61
81
  export function mergeMissingSettingsDefaults(targetSettings, settingsDefaults) {
62
82
  const addedKeys = [];
63
83
  for (const [settingKey, defaultValue] of Object.entries(settingsDefaults)) {
64
- const isPerModelMerge = PER_MODEL_SETTING_KEYS.includes(settingKey)
65
- && isPlainObject(targetSettings[settingKey])
66
- && isPlainObject(defaultValue);
67
- if (isPerModelMerge) {
68
- addedKeys.push(...mergeMissingPerModelFields(targetSettings[settingKey], defaultValue, settingKey));
84
+ const mergeNested = NESTED_MERGE_BY_SETTING_KEY.get(settingKey);
85
+ if (mergeNested && isPlainObject(targetSettings[settingKey]) && isPlainObject(defaultValue)) {
86
+ addedKeys.push(...mergeNested(targetSettings[settingKey], defaultValue, settingKey));
69
87
  continue;
70
88
  }
71
- if (Object.hasOwn(targetSettings, settingKey)) continue;
72
- targetSettings[settingKey] = structuredClone(defaultValue);
73
- addedKeys.push(settingKey);
89
+ if (addMissingField(targetSettings, settingKey, defaultValue)) {
90
+ addedKeys.push(settingKey);
91
+ }
74
92
  }
75
93
  return { addedKeys };
76
94
  }
@@ -94,3 +94,45 @@ test('mergeMissingSettingsDefaults should keep a per-model value the user alread
94
94
  );
95
95
  assert.deepEqual(targetSettings.modelSettings, { 'claude-haiku-5-5': { autoCompactWindow: 150000 } });
96
96
  });
97
+
98
+ test('mergeMissingSettingsDefaults should add a missing env variable to a present env block and keep the user values', () => {
99
+ const targetSettings = {
100
+ env: { CLAUDE_MERGE_REVIEW_COMMAND: 'review', CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '300000' },
101
+ };
102
+
103
+ assert.deepEqual(
104
+ mergeMissingSettingsDefaults(targetSettings, {
105
+ env: { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000', EDITOR: 'code' },
106
+ }),
107
+ { addedKeys: ['env.EDITOR'] },
108
+ );
109
+ assert.deepEqual(targetSettings, {
110
+ env: {
111
+ CLAUDE_MERGE_REVIEW_COMMAND: 'review',
112
+ CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '300000',
113
+ EDITOR: 'code',
114
+ },
115
+ });
116
+ });
117
+
118
+ test('mergeMissingSettingsDefaults should add the whole env block when the user settings have none', () => {
119
+ const targetSettings = {};
120
+
121
+ assert.deepEqual(
122
+ mergeMissingSettingsDefaults(targetSettings, { env: { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000' } }),
123
+ { addedKeys: ['env'] },
124
+ );
125
+ assert.deepEqual(targetSettings, { env: { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000' } });
126
+ });
127
+
128
+ test('mergeMissingSettingsDefaults should keep a non-object env value as the user set it', () => {
129
+ for (const userEnv of [null, ['CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS=300000']]) {
130
+ const targetSettings = { env: structuredClone(userEnv) };
131
+
132
+ assert.deepEqual(
133
+ mergeMissingSettingsDefaults(targetSettings, { env: { CLAUDE_CODE_IDLE_COMPACT_MIN_TOKENS: '100000' } }),
134
+ { addedKeys: [] },
135
+ );
136
+ assert.deepEqual(targetSettings, { env: userEnv });
137
+ }
138
+ });
@@ -18,7 +18,7 @@ python scripts/account_broker.py check --product codex
18
18
  printf "%s" "prompt" | python scripts/account_broker.py run --product claude --report report.json -- claude --output-format json -p
19
19
  ```
20
20
 
21
- `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
21
+ `accounts` prints the roster without reading meters. `choose` prints `decision`, `accounts`, and `state_path`. Each decision contains `action`, `account`, `profile`, `home`, `reason`, `tier`, and `resets_at`. `profile` is the name of the profile whose home the account uses, such as `editor` for a home at `~/.claude-profiles/editor`. It is null for the main account, a Codex account, a home outside the profiles root, a home whose folder name is not a valid profile name such as `main`, and a wait decision. `--spent` takes an account's name or its profile. The account order file matches the name, command, or launcher, and resume affinity stores the command, or the name for an account without one. When a mark is one account's name and another account's profile, the name wins. Every mark is read before the first one is saved, so an unreadable mark saves none. `accounts` and the `choose` account list carry the same `profile` field. A wait decision has no account or home and includes the soonest reset in UTC. An account counts as free at the latest reset of the windows that hold it, and one hour out when one of those windows has no reset time. A failed meter read excludes the account for that choice. `--spent` saves an exclusion until the supplied Unix reset, the account's soonest known meter reset, or one hour when neither is available. A `--spent` name absent from the roster is saved until the supplied Unix reset, or for one hour when no reset is given. A mark splits at its last colon, so an absent name that contains a colon, such as `C:\bin\old.cmd`, takes an explicit reset, such as `C:\bin\old.cmd:1800000000`. While any such mark is still in the future, `choose` waits until the soonest mark and exits 3, and with no Codex roster `--spent default` waits until that mark passes. Meter reads younger than 60 seconds are reused.
22
22
 
23
23
  `check` prints nothing and exits 3 while every account is below its floor. `run` sets `CLAUDE_CONFIG_DIR` or `CODEX_HOME` for each attempt. A Claude job also starts without the parent session's variables, such as `CLAUDE_CODE_SESSION_ID` and `CLAUDECODE`, so a child started from inside a Claude Code session runs as its own session. `ALL_PARENT_CLAUDE_SESSION_VARIABLES` lists them. It replays the same stdin bytes when a usage limit or start failure leads to another account. Send the prompt on stdin. When the command resolves to a `.cmd` or `.bat` file, the broker refuses an argument that holds a cmd.exe metacharacter and records `start_failed`. It writes the command's stdout to stdout and diagnostics to stderr. A resumed Claude session uses the account bound to its session when that account has room.
24
24
 
@@ -12,7 +12,7 @@ A job asks the broker which account to use.
12
12
  | `scripts/account_broker.py` | `choose --product codex` names the account and tier a job runs on; `check --product codex` reports whether any account has room |
13
13
  | `scripts/codex_account_choice.py` | `sync` links the shared setup into every account's home; `setup` and `install` write one launcher per account |
14
14
  | `scripts/codex_account_meters.py` | Reads one account's rate-limit windows through `codex app-server` with `CODEX_HOME` set to that account's home |
15
- | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% 5-hour floor for Luna |
15
+ | `scripts/dev_env_scripts_constants/codex_account_constants.py` | The fallback account names, the roster variable and file, the launcher template, the shared entry names, the 10% bar, the 1% Luna stop, and the 20% short-window floor for Luna |
16
16
 
17
17
  ## Names and order
18
18
 
@@ -86,16 +86,17 @@ aside.
86
86
 
87
87
  ## Which account a job uses
88
88
 
89
- Room is the smaller of an account's two windows: the 5-hour window and the week.
89
+ Room is the least percent left across an account's windows, such as the 5-hour window and the week.
90
+ A window that reports no length counts as weekly.
90
91
 
91
92
  | Condition | Answer |
92
93
  |---|---|
93
94
  | One or more accounts with more than 10% left | `normal` on the account with the most room |
94
95
  | No account over 10%, one or more over 1% | `luna` on the account with the most room |
95
- | An account that reports a 5-hour window, with under 20% of that window left | never takes `luna` |
96
+ | An account that reports a window shorter than a week, such as the 5-hour window, with under 20% of that window left | never takes `luna` |
96
97
  | No account over 1% | `wait`, naming the next reset when known |
97
- | A meter read fails | The broker reads it once more |
98
- | A meter is unread after both reads | The account has `meters: null` and the failure text in `unread_reason`; a `wait` reason names the account and that text |
98
+ | A meter read fails | The broker reads it again, up to 3 reads, waiting 1 second and then 2 seconds |
99
+ | A meter is unread after every read | The account has `meters: null` and the failure text in `unread_reason`; a `wait` reason names the account and that text |
99
100
 
100
101
  ```
101
102
  python packages/claude-dev-env/scripts/account_broker.py choose --product codex
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse gate: keep cloud sessions from calling GitHub GraphQL.
3
+
4
+ The Claude Code cloud proxy answers every request to
5
+ ``api.github.com/graphql`` with HTTP 403, an unauthenticated one included, so
6
+ no token or setting makes the call work there. Inside a cloud session, where
7
+ ``CLAUDE_CODE_REMOTE`` is ``true``, a command segment is denied when it runs
8
+ a ``gh`` command that calls GraphQL or points an HTTP client such as ``curl``
9
+ at the GraphQL endpoint. The deny reason names the REST routes that do the same
10
+ jobs. ``gh`` calls GraphQL for ``api graphql``, for every ``pr``, ``issue``,
11
+ ``project`` and ``discussion`` subcommand except ``pr diff`` and ``pr list
12
+ --web``, and for ``status``, ``org list``, ``repo view``, ``repo clone``,
13
+ ``repo list``, ``repo archive``, ``repo unarchive``, ``label list``, ``search
14
+ prs``, ``search issues``, ``ruleset list``, ``gist list``, ``release list``,
15
+ ``release view`` with a tag, ``workflow run`` without ``--ref``, ``repo sync``
16
+ without ``--branch``, ``repo create --template``, and ``repo edit`` with a
17
+ security setting. An interactive ``repo edit`` needs a terminal, which an agent
18
+ call lacks, so gh stops before any request. The command
19
+ words skip options and the values of ``-R``, ``--json`` and the like, so ``gh -R o/r pr list``
20
+ and ``gh api -X POST graphql`` read as ``pr list`` and ``api graphql``. A
21
+ ``gh`` call with ``--help`` or ``-h`` passes, because it reaches no network,
22
+ unless the flag is the separate value of an option such as ``--body``. An HTTP client or a script
23
+ interpreter such as ``python -c`` is denied when an argument names
24
+ ``api.github.com/graphql``, with or without a port. A URL held in a shell
25
+ variable, or built inside a script file, is not in the command text, so the
26
+ gate cannot see it. The programs checked for the endpoint are a fixed list, so
27
+ another program that names it, such as ``java`` or ``dotnet``, passes and gets
28
+ the proxy's 403. The program is read past wrappers, shell keywords such as
29
+ ``if`` and ``!``, command substitutions, ``eval``, ``Invoke-Expression``,
30
+ ``find -exec``, ``Start-Process``, PowerShell ``-EncodedCommand`` and chained
31
+ commands. Outside a
32
+ cloud session the gate stays quiet, because GraphQL works there. Section 7.1 of
33
+ ``docs/references/cloud-pr-loop-compatibility.md`` holds the probe or gh source
34
+ line behind each command in the map.
35
+
36
+ Hosted by ``blocking/bash_pre_tool_use_dispatcher.py`` for the Bash and
37
+ PowerShell tools.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import sys
44
+ from pathlib import Path
45
+
46
+ try:
47
+ _hooks_root_directory = str(Path(__file__).resolve().parent.parent)
48
+ if _hooks_root_directory not in sys.path:
49
+ sys.path.insert(0, _hooks_root_directory)
50
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
51
+ ALL_BASH_AND_POWERSHELL_TOOL_NAMES,
52
+ DENY_DECISION,
53
+ HOOK_EVENT_NAME,
54
+ )
55
+ from hooks_constants.cloud_graphql_gate_constants import (
56
+ ALL_GH_COMMAND_PATHS_CALLING_GRAPHQL_WITH_AN_OPERAND,
57
+ ALL_GH_GRAPHQL_COMMAND_GROUPS,
58
+ ALL_GH_GRAPHQL_COMMAND_PATHS,
59
+ ALL_GH_GRAPHQL_OPTIONS_BY_COMMAND_PATH,
60
+ ALL_GH_HELP_FLAGS,
61
+ ALL_GH_REST_COMMAND_PATHS_IN_GRAPHQL_GROUPS,
62
+ ALL_GH_REST_OPTIONS_BY_COMMAND_PATH,
63
+ ALL_HTTP_CLIENT_PROGRAM_NAMES,
64
+ ALL_SCRIPT_INTERPRETER_PROGRAM_NAMES,
65
+ CLOUD_GRAPHQL_DENY_REASON,
66
+ GATE_HOOK_NAME,
67
+ GH_API_SUBCOMMAND,
68
+ GH_COMMAND_PATH_LENGTH,
69
+ GH_ENDPOINT_PATH_SEPARATOR,
70
+ GH_GRAPHQL_ENDPOINT,
71
+ GH_PROGRAM_NAME,
72
+ GITHUB_GRAPHQL_ENDPOINT_PATTERN,
73
+ )
74
+ from hooks_constants.cloud_session import is_cloud_session
75
+ from hooks_constants.gh_command_words import gh_command_words_and_options
76
+ from hooks_constants.hook_block_logger import log_hook_block
77
+ from hooks_constants.piped_pytest_blocker_constants import PYTHON_INTERPRETER_BASENAME_PATTERN
78
+ from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
79
+ from hooks_constants.shell_command_wrappers import (
80
+ all_wrapped_command_segments,
81
+ segment_program_and_arguments,
82
+ )
83
+ except ImportError as import_error:
84
+ raise ImportError(
85
+ "The cloud GraphQL gate cannot import its dependencies; "
86
+ "ensure the hooks directory is importable."
87
+ ) from import_error
88
+
89
+
90
+ def _is_graphql_endpoint(endpoint: str) -> bool:
91
+ """Return True when a ``gh api`` endpoint word is ``graphql``, ``/graphql`` or the full URL."""
92
+ return (
93
+ endpoint.strip(GH_ENDPOINT_PATH_SEPARATOR).lower() == GH_GRAPHQL_ENDPOINT
94
+ or GITHUB_GRAPHQL_ENDPOINT_PATTERN.search(endpoint) is not None
95
+ )
96
+
97
+
98
+ def _gh_arguments_call_graphql(all_arguments: list[str]) -> bool:
99
+ """Return True when gh, run with these arguments, sends a GraphQL request.
100
+
101
+ Args:
102
+ all_arguments: The tokens after the ``gh`` program name.
103
+ """
104
+ all_command_words, all_option_names = gh_command_words_and_options(all_arguments)
105
+ if any(each_option in ALL_GH_HELP_FLAGS for each_option in all_option_names):
106
+ return False
107
+ if all_command_words[:1] == [GH_API_SUBCOMMAND]:
108
+ return any(_is_graphql_endpoint(each_word) for each_word in all_command_words[1:])
109
+ command_path = tuple(all_command_words[:GH_COMMAND_PATH_LENGTH])
110
+ all_rest_options = ALL_GH_REST_OPTIONS_BY_COMMAND_PATH.get(command_path, frozenset())
111
+ if any(each_option in all_rest_options for each_option in all_option_names):
112
+ return False
113
+ all_graphql_options = ALL_GH_GRAPHQL_OPTIONS_BY_COMMAND_PATH.get(command_path, frozenset())
114
+ if any(each_option in all_graphql_options for each_option in all_option_names):
115
+ return True
116
+ if command_path in ALL_GH_COMMAND_PATHS_CALLING_GRAPHQL_WITH_AN_OPERAND:
117
+ return len(all_command_words) > GH_COMMAND_PATH_LENGTH
118
+ if any(
119
+ command_path[: len(each_path)] == each_path for each_path in ALL_GH_GRAPHQL_COMMAND_PATHS
120
+ ):
121
+ return True
122
+ return (
123
+ len(command_path) == GH_COMMAND_PATH_LENGTH
124
+ and command_path[0] in ALL_GH_GRAPHQL_COMMAND_GROUPS
125
+ and command_path not in ALL_GH_REST_COMMAND_PATHS_IN_GRAPHQL_GROUPS
126
+ )
127
+
128
+
129
+ def _calls_github_graphql(all_segment_tokens: list[str]) -> bool:
130
+ """Return True when a segment runs a GraphQL gh command or sends an HTTP client to the endpoint.
131
+
132
+ Args:
133
+ all_segment_tokens: One command segment's shell tokens.
134
+ """
135
+ program_name, all_arguments = segment_program_and_arguments(all_segment_tokens)
136
+ normalized_program_name = program_name.lower()
137
+ if normalized_program_name == GH_PROGRAM_NAME:
138
+ return _gh_arguments_call_graphql(all_arguments)
139
+ if (
140
+ normalized_program_name in ALL_HTTP_CLIENT_PROGRAM_NAMES
141
+ or normalized_program_name in ALL_SCRIPT_INTERPRETER_PROGRAM_NAMES
142
+ or PYTHON_INTERPRETER_BASENAME_PATTERN.fullmatch(normalized_program_name) is not None
143
+ ):
144
+ return any(
145
+ GITHUB_GRAPHQL_ENDPOINT_PATTERN.search(each_argument) is not None
146
+ for each_argument in all_arguments
147
+ )
148
+ return False
149
+
150
+
151
+ def calls_github_graphql(command: str) -> bool:
152
+ """Return True when any command segment calls the GitHub GraphQL API.
153
+
154
+ ::
155
+
156
+ gh api graphql -f query='query{viewer{login}}' -> True
157
+ gh pr create --title t --body-file b.md -> True
158
+ gh api -X POST graphql -f query='query{viewer{login}}' -> True
159
+ gh -R o/r pr list -> True
160
+ curl -X POST https://api.github.com/graphql -d @q -> True
161
+ gh api repos/o/r/pulls/1/ccr/review_threads -> False
162
+ gh pr diff 12 -> False
163
+ gh pr list --web -> False
164
+ grep -rn "api.github.com/graphql" scripts -> False
165
+
166
+ Args:
167
+ command: The shell command text the agent is about to run.
168
+ """
169
+ return any(
170
+ _calls_github_graphql(each_segment)
171
+ for each_segment in all_wrapped_command_segments(command)
172
+ )
173
+
174
+
175
+ def _write_deny_decision() -> None:
176
+ sys.stdout.write(
177
+ json.dumps(
178
+ {
179
+ "hookSpecificOutput": {
180
+ "hookEventName": HOOK_EVENT_NAME,
181
+ "permissionDecision": DENY_DECISION,
182
+ "permissionDecisionReason": CLOUD_GRAPHQL_DENY_REASON,
183
+ }
184
+ }
185
+ )
186
+ )
187
+ sys.stdout.flush()
188
+
189
+
190
+ def main() -> None:
191
+ """Deny a GitHub GraphQL call inside a cloud session, or stay quiet."""
192
+ if not is_cloud_session():
193
+ return
194
+ hook_payload = read_hook_input_dictionary_from_stdin()
195
+ if hook_payload is None:
196
+ return
197
+ tool_name = hook_payload.get("tool_name")
198
+ if tool_name not in ALL_BASH_AND_POWERSHELL_TOOL_NAMES:
199
+ return
200
+ tool_input = hook_payload.get("tool_input")
201
+ if not isinstance(tool_input, dict):
202
+ return
203
+ command = tool_input.get("command", "")
204
+ if not isinstance(command, str) or not calls_github_graphql(command):
205
+ return
206
+ log_hook_block(
207
+ GATE_HOOK_NAME, HOOK_EVENT_NAME, CLOUD_GRAPHQL_DENY_REASON, str(tool_name), command
208
+ )
209
+ _write_deny_decision()
210
+
211
+
212
+ if __name__ == "__main__":
213
+ main()
@@ -19,7 +19,6 @@ PowerShell tools.
19
19
  from __future__ import annotations
20
20
 
21
21
  import json
22
- import os
23
22
  import sys
24
23
  from pathlib import Path
25
24
 
@@ -32,14 +31,13 @@ try:
32
31
  DENY_DECISION,
33
32
  HOOK_EVENT_NAME,
34
33
  )
34
+ from hooks_constants.cloud_session import is_cloud_session
35
35
  from hooks_constants.headless_claude_broker_gate_constants import (
36
36
  ALL_PRINT_MODE_FLAGS,
37
37
  ALL_WINDOWS_LAUNCHER_SUFFIXES,
38
38
  BROKER_COMMAND_DENY_REASON,
39
39
  CLAUDE_PROFILE_LAUNCHER_PREFIX,
40
40
  CLAUDE_PROGRAM_NAME,
41
- CLOUD_SESSION_ENV_TRUE_VALUE,
42
- CLOUD_SESSION_ENV_VAR,
43
41
  GATE_HOOK_NAME,
44
42
  PRINT_MODE_LONG_FLAG_WITH_VALUE_PREFIX,
45
43
  )
@@ -119,11 +117,6 @@ def starts_unbrokered_headless_claude(command: str) -> bool:
119
117
  )
120
118
 
121
119
 
122
- def is_cloud_session() -> bool:
123
- """Return True when this hook runs inside a Claude Code cloud session."""
124
- return os.environ.get(CLOUD_SESSION_ENV_VAR, "").strip().lower() == CLOUD_SESSION_ENV_TRUE_VALUE
125
-
126
-
127
120
  def main() -> None:
128
121
  """Deny a headless Claude start that skips the broker outside a cloud session, or stay quiet."""
129
122
  if is_cloud_session():
@@ -1,13 +1,15 @@
1
1
  """Deny an agent's merge until a review of the merged head came back clean.
2
2
 
3
3
  The agent that drives a pull request loops a code review on it, fixing and
4
- pushing each finding, until one pass reports zero findings. The merge then
4
+ pushing each finding, until one pass reports zero findings or every finding
5
+ it reports is ruled out with a link to the posted rulings. The merge then
5
6
  names that exact head::
6
7
 
7
8
  merge, receipt says 0 findings at abc123, merge pinned to abc123 -> allow
9
+ merge, receipt rules out all 3 findings with a link, pinned -> allow
8
10
  merge, receipt at abc123, merge pinned to def456 -> deny, review again
9
11
  merge, receipt at abc123, merge names no head -> deny, pin it
10
- merge, no receipt or the receipt counts findings -> deny, run the loop
12
+ merge, no receipt or the receipt counts open findings -> deny, run the loop
11
13
  auto-merge or enqueue pinned to the clean head -> allow
12
14
  auto-merge that names no head -> deny, pin it
13
15
  repository listed in CLAUDE_MERGE_REVIEW_EXEMPT_REPOSITORIES -> allow
@@ -61,10 +63,13 @@ from hooks_constants.merge_review_gate_constants import (
61
63
  RECEIPT_FILE_NAME_TEMPLATE,
62
64
  RECEIPT_FINDINGS_KEY,
63
65
  RECEIPT_HEAD_KEY,
66
+ RECEIPT_RULED_OUT_KEY,
67
+ RECEIPT_RULINGS_KEY,
64
68
  REPO_FIELD,
65
69
  REPO_OPTION_PATTERN,
66
70
  REPOSITORY_SEPARATOR,
67
71
  REVIEW_COMMAND_ENVIRONMENT_VARIABLE,
72
+ RULINGS_LINK_PATTERN,
68
73
  STALE_REVIEW_REASON_TEMPLATE,
69
74
  UNKNOWN_TARGET_REASON,
70
75
  UNPINNED_MERGE_REASON_TEMPLATE,
@@ -228,12 +233,69 @@ def _clean_head(request: MergeRequest) -> str | None:
228
233
  )
229
234
  except (OSError, ValueError):
230
235
  return None
231
- if not isinstance(receipt, dict) or receipt.get(RECEIPT_FINDINGS_KEY) != 0:
236
+ if not isinstance(receipt, dict) or not is_clean_receipt(
237
+ receipt, request.owner, request.repo, request.number
238
+ ):
232
239
  return None
233
240
  head = receipt.get(RECEIPT_HEAD_KEY)
234
241
  return head if isinstance(head, str) and head else None
235
242
 
236
243
 
244
+ def is_clean_receipt(
245
+ all_receipt_fields: dict[str, object], owner: str, repo: str, number: int
246
+ ) -> bool:
247
+ """Return whether a recorded pass lets the merge through.
248
+
249
+ A pass is clean when it reported zero findings, or when every finding it
250
+ reported is ruled out and the receipt links the rulings comment posted on
251
+ this same pull request::
252
+
253
+ findings 0 -> clean
254
+ findings 2, ruled out 2, .../acme/tools/pull/12#issuecomment-9 -> clean
255
+ findings 2, ruled out 2, .../acme/other/pull/12#issuecomment-9 -> not clean
256
+
257
+ Args:
258
+ all_receipt_fields: The parsed receipt of one review pass.
259
+ owner: Owner of the pull request the merge targets.
260
+ repo: Repository of the pull request the merge targets.
261
+ number: Number of the pull request the merge targets.
262
+
263
+ Returns:
264
+ True when the pass counts as clean.
265
+ """
266
+ finding_count = all_receipt_fields.get(RECEIPT_FINDINGS_KEY)
267
+ if not _is_count(finding_count):
268
+ return False
269
+ return finding_count == 0 or _rules_out_every_finding(
270
+ all_receipt_fields, finding_count, owner, repo, number
271
+ )
272
+
273
+
274
+ def _is_count(candidate: object) -> bool:
275
+ return isinstance(candidate, int) and not isinstance(candidate, bool) and candidate >= 0
276
+
277
+
278
+ def _rules_out_every_finding(
279
+ all_receipt_fields: dict[str, object], finding_count: object, owner: str, repo: str, number: int
280
+ ) -> bool:
281
+ ruled_out_count = all_receipt_fields.get(RECEIPT_RULED_OUT_KEY)
282
+ return (
283
+ _is_count(ruled_out_count)
284
+ and ruled_out_count == finding_count
285
+ and _links_rulings_on(all_receipt_fields.get(RECEIPT_RULINGS_KEY), owner, repo, number)
286
+ )
287
+
288
+
289
+ def _links_rulings_on(rulings: object, owner: str, repo: str, number: int) -> bool:
290
+ rulings_match = RULINGS_LINK_PATTERN.fullmatch(rulings) if isinstance(rulings, str) else None
291
+ return (
292
+ rulings_match is not None
293
+ and rulings_match["owner"].casefold() == owner.casefold()
294
+ and rulings_match["repo"].casefold() == repo.casefold()
295
+ and int(rulings_match["number"]) == number
296
+ )
297
+
298
+
237
299
  def _review_command() -> str:
238
300
  return os.environ.get(REVIEW_COMMAND_ENVIRONMENT_VARIABLE) or DEFAULT_REVIEW_COMMAND
239
301
 
@@ -32,7 +32,6 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
32
32
  DENY_REASON,
33
33
  ENVIRONMENT_ASSIGNMENT_PATTERN,
34
34
  GH_API_COMMAND,
35
- GH_OPTIONS_WITH_VALUE,
36
35
  GH_PULL_REQUEST_COMMAND,
37
36
  GIT_ACTIONS,
38
37
  GIT_GLOBAL_OPTIONS_WITH_VALUE,
@@ -49,6 +48,7 @@ from hooks_constants.pr_lifecycle_skill_gate_constants import (
49
48
  SUBAGENT_TRANSCRIPT_DIRECTORY_NAME,
50
49
  SUBAGENT_TRANSCRIPT_FILE_TEMPLATE,
51
50
  )
51
+ from hooks_constants.gh_command_words import gh_command_words
52
52
  from hooks_constants.hook_specific_output_keys import (
53
53
  HOOK_SPECIFIC_OUTPUT_KEY,
54
54
  PERMISSION_DECISION_KEY,
@@ -113,13 +113,12 @@ def _is_git_action(all_words: list[str]) -> bool:
113
113
 
114
114
 
115
115
  def _is_gh_action(all_words: list[str]) -> bool:
116
- action, next_index = _next_command_word(all_words, 1, GH_OPTIONS_WITH_VALUE)
117
- if action == GH_PULL_REQUEST_COMMAND:
118
- subcommand, _ = _next_command_word(all_words, next_index, GH_OPTIONS_WITH_VALUE)
119
- return subcommand is not None
120
- if action != GH_API_COMMAND:
116
+ all_gh_words = gh_command_words(all_words[1:])
117
+ if all_gh_words[:1] == [GH_PULL_REQUEST_COMMAND]:
118
+ return len(all_gh_words) > 1
119
+ if all_gh_words[:1] != [GH_API_COMMAND]:
121
120
  return False
122
- all_api_arguments = all_words[next_index:]
121
+ all_api_arguments = all_words[all_words.index(GH_API_COMMAND, 1) + 1 :]
123
122
  if any(each_name in each_word for each_word in all_api_arguments for each_name in ALL_API_ACTION_NAMES):
124
123
  return True
125
124
  return any(