claude-dev-env 8.78.3 → 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.
- package/.agents/skills/pr-lifecycle/reference/review-loop-before-merge.md +2 -2
- package/bin/install.settings-defaults.test.mjs +27 -2
- package/bin/merge_settings_defaults.mjs +39 -21
- package/bin/merge_settings_defaults.test.mjs +42 -0
- package/docs/account-broker.md +1 -1
- package/docs/codex-accounts.md +6 -5
- package/hooks/blocking/cloud_graphql_gate.py +213 -0
- package/hooks/blocking/headless_claude_broker_gate.py +1 -8
- package/hooks/blocking/merge_over_review_offer_gate.py +114 -0
- package/hooks/blocking/merge_review_gate.py +65 -3
- package/hooks/blocking/pr_lifecycle_skill_gate.py +6 -7
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +5 -1
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +4 -1
- package/hooks/blocking/test_cloud_graphql_gate.py +210 -0
- package/hooks/blocking/test_headless_claude_broker_gate.py +12 -7
- package/hooks/blocking/test_merge_over_review_offer_gate.py +138 -0
- package/hooks/blocking/test_merge_review_gate.py +63 -0
- package/hooks/blocking/test_pr_lifecycle_skill_gate.py +3 -0
- package/hooks/conftest.py +1 -1
- package/hooks/features/bash-dispatchers.md +3 -0
- package/hooks/features/conduct-gates.md +5 -2
- package/hooks/hooks.json +10 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +4 -0
- package/hooks/hooks_constants/cloud_graphql_gate_constants.py +116 -0
- package/hooks/hooks_constants/cloud_session.py +17 -0
- package/hooks/hooks_constants/cloud_session_constants.py +11 -0
- package/hooks/hooks_constants/gh_command_words.py +52 -0
- package/hooks/hooks_constants/gh_command_words_constants.py +64 -0
- package/hooks/hooks_constants/headless_claude_broker_gate_constants.py +1 -7
- package/hooks/hooks_constants/hook_block_logger.py +21 -3
- package/hooks/hooks_constants/merge_over_review_offer_gate_constants.py +34 -0
- package/hooks/hooks_constants/merge_review_gate_constants.py +14 -3
- package/hooks/hooks_constants/mod_handoff.py +2 -3
- package/hooks/hooks_constants/mod_handoff_constants.py +0 -4
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +27 -2
- package/hooks/hooks_constants/pr_lifecycle_skill_gate_constants.py +0 -1
- package/hooks/hooks_constants/shell_command_evaluation_constants.py +67 -0
- package/hooks/hooks_constants/shell_command_substitution_constants.py +25 -0
- package/hooks/hooks_constants/shell_command_wrappers.py +327 -12
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +5 -1
- package/hooks/hooks_constants/test_cloud_session.py +27 -0
- package/hooks/hooks_constants/test_gh_command_words.py +45 -0
- package/hooks/hooks_constants/test_hook_block_logger.py +61 -0
- package/hooks/hooks_constants/test_mod_handoff.py +3 -2
- package/hooks/hooks_constants/test_piped_pytest_blocker_constants.py +11 -0
- package/hooks/hooks_constants/test_shell_command_wrappers.py +125 -0
- package/hooks/test_conftest.py +1 -1
- package/package.json +1 -1
- package/scripts/account_broker.py +23 -11
- package/scripts/account_broker_support.py +23 -14
- package/scripts/codex_account_meters.py +30 -43
- package/scripts/conftest.py +25 -0
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +36 -25
- package/scripts/dev_env_scripts_constants/claude_account_constants.py +0 -9
- package/scripts/dev_env_scripts_constants/codex_account_constants.py +0 -18
- package/scripts/dev_env_scripts_constants/test_account_broker_constants.py +57 -14
- package/scripts/dev_env_scripts_constants/test_codex_account_constants.py +119 -0
- package/scripts/merge_review_receipt.py +56 -12
- package/scripts/policy_lint/adapter_configuration.py +5 -0
- package/scripts/policy_lint/config/constants.py +1 -0
- package/scripts/test_account_broker.py +145 -3
- package/scripts/test_account_broker_support.py +88 -6
- package/scripts/test_claude_chain_usage.py +3 -11
- package/scripts/test_codex_account_meters.py +26 -14
- package/scripts/test_merge_review_receipt.py +86 -0
- package/scripts/test_shared_tree_paths.py +58 -36
- package/scripts/tests/test_adapter_configuration.py +8 -0
- 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
|
|
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
|
|
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
|
|
6
|
-
* user settings lack that key, so a value the user already set stays as
|
|
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
|
|
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 (
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
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 (
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
});
|
package/docs/account-broker.md
CHANGED
|
@@ -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`, `home`, `reason`, `tier`, and `resets_at`. 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. 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
|
|
package/docs/codex-accounts.md
CHANGED
|
@@ -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%
|
|
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
|
|
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
|
|
98
|
-
| A meter is unread after
|
|
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():
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Deny a decision card or question that offers a merge over open review findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
hooks_directory = str(Path(__file__).resolve().parent.parent)
|
|
9
|
+
if hooks_directory not in sys.path:
|
|
10
|
+
sys.path.insert(0, hooks_directory)
|
|
11
|
+
|
|
12
|
+
from hooks_constants.hook_specific_output_keys import (
|
|
13
|
+
HOOK_EVENT_NAME_KEY,
|
|
14
|
+
HOOK_SPECIFIC_OUTPUT_KEY,
|
|
15
|
+
PERMISSION_DECISION_KEY,
|
|
16
|
+
)
|
|
17
|
+
from hooks_constants.merge_over_review_offer_gate_constants import (
|
|
18
|
+
ALL_CARD_TEXT_FIELDS,
|
|
19
|
+
ALL_OPTION_TEXT_FIELDS,
|
|
20
|
+
CLEAN_REVIEW_PATTERN,
|
|
21
|
+
DENY_DECISION,
|
|
22
|
+
HOOK_EVENT_NAME,
|
|
23
|
+
MERGE_OFFER_PATTERN,
|
|
24
|
+
MERGE_OVER_REVIEW_OFFER_REASON,
|
|
25
|
+
OPEN_REVIEW_PATTERN,
|
|
26
|
+
OPTIONS_FIELD,
|
|
27
|
+
PERMISSION_DECISION_REASON_KEY,
|
|
28
|
+
QUESTIONS_FIELD,
|
|
29
|
+
TEXT_SEPARATOR,
|
|
30
|
+
TOOL_INPUT_KEY,
|
|
31
|
+
)
|
|
32
|
+
from hooks_constants.pre_tool_use_stdin import print_decision_from_stdin
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _one_option_text(each_option: object) -> str:
|
|
36
|
+
if isinstance(each_option, dict):
|
|
37
|
+
return TEXT_SEPARATOR.join(
|
|
38
|
+
str(each_option.get(each_field, "")) for each_field in ALL_OPTION_TEXT_FIELDS
|
|
39
|
+
)
|
|
40
|
+
return str(each_option)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _block_texts(block_by_key: dict[str, object]) -> list[str]:
|
|
44
|
+
all_texts = [str(block_by_key.get(each_field, "")) for each_field in ALL_CARD_TEXT_FIELDS]
|
|
45
|
+
all_options = block_by_key.get(OPTIONS_FIELD)
|
|
46
|
+
if isinstance(all_options, list):
|
|
47
|
+
all_texts.extend(_one_option_text(each_option) for each_option in all_options)
|
|
48
|
+
return all_texts
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _card_text(card_by_key: dict[str, object]) -> str:
|
|
52
|
+
all_questions = card_by_key.get(QUESTIONS_FIELD)
|
|
53
|
+
if not isinstance(all_questions, list):
|
|
54
|
+
all_questions = []
|
|
55
|
+
all_blocks = [card_by_key] + [
|
|
56
|
+
each_question for each_question in all_questions if isinstance(each_question, dict)
|
|
57
|
+
]
|
|
58
|
+
return TEXT_SEPARATOR.join(
|
|
59
|
+
each_text for each_block in all_blocks for each_text in _block_texts(each_block)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def offers_merge_over_review(card_by_key: dict[str, object]) -> bool:
|
|
64
|
+
"""Return whether a card offers a merge while it names open review findings.
|
|
65
|
+
|
|
66
|
+
The whole card is read as one text: every question, header, context,
|
|
67
|
+
reason, and option, across both card shapes, an ``ask_decision`` card and
|
|
68
|
+
an ``AskUserQuestion`` questions list.
|
|
69
|
+
|
|
70
|
+
::
|
|
71
|
+
|
|
72
|
+
option "Merge now", context "9 findings remain" -> True
|
|
73
|
+
question 1 "The review reports 9 findings.",
|
|
74
|
+
question 2 "Merge it now?" with "Yes" / "No" -> True
|
|
75
|
+
context "I can merge it now, but 9 findings remain." -> True
|
|
76
|
+
option "Join the merge queue", context "9 findings" -> True
|
|
77
|
+
context "The merge review check reports 11 findings",
|
|
78
|
+
options "Keep fixing" / "Split it" -> False
|
|
79
|
+
option "Merge", context "the review found no findings" -> False
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
card_by_key: The card's tool input.
|
|
83
|
+
"""
|
|
84
|
+
card_text = _card_text(card_by_key)
|
|
85
|
+
if not MERGE_OFFER_PATTERN.search(card_text):
|
|
86
|
+
return False
|
|
87
|
+
return OPEN_REVIEW_PATTERN.search(CLEAN_REVIEW_PATTERN.sub("", card_text)) is not None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def decision_for(payload_by_key: dict[str, object]) -> dict[str, object] | None:
|
|
91
|
+
"""Return a deny for a card that offers a merge over open findings, else None.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
payload_by_key: The parsed PreToolUse input.
|
|
95
|
+
"""
|
|
96
|
+
card_by_key = payload_by_key.get(TOOL_INPUT_KEY)
|
|
97
|
+
if not isinstance(card_by_key, dict) or not offers_merge_over_review(card_by_key):
|
|
98
|
+
return None
|
|
99
|
+
return {
|
|
100
|
+
HOOK_SPECIFIC_OUTPUT_KEY: {
|
|
101
|
+
HOOK_EVENT_NAME_KEY: HOOK_EVENT_NAME,
|
|
102
|
+
PERMISSION_DECISION_KEY: DENY_DECISION,
|
|
103
|
+
PERMISSION_DECISION_REASON_KEY: MERGE_OVER_REVIEW_OFFER_REASON,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main() -> int:
|
|
109
|
+
"""Read one payload, print a deny when needed, and exit zero."""
|
|
110
|
+
return print_decision_from_stdin(decision_for)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
sys.exit(main())
|