claude-dev-env 7.0.0 → 7.2.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/pull-request/scripts/pull_request.py +2 -0
- package/.agents/skills/pull-request/scripts/test_pull_request.py +34 -0
- package/.agents/skills/session-continuity/AGENTS.md +1 -0
- package/.agents/skills/session-continuity/CLAUDE.md +1 -0
- package/.agents/skills/session-continuity/README.md +213 -0
- package/.agents/skills/session-continuity/SKILL.md +125 -0
- package/.agents/skills/session-continuity/continuity.mjs +447 -0
- package/.agents/skills/session-continuity/continuity.test.mjs +373 -0
- package/.agents/skills/syncing-submodules/scripts/test_sync_parent_pointer.py +0 -7
- package/.agents/skills/windows-scheduled-task/SKILL.md +153 -0
- package/bin/cde.mjs +36 -8
- package/bin/cde.test.mjs +48 -0
- package/bin/ever-shipped-skills.mjs +2 -20
- package/bin/ever-shipped-skills.test.mjs +8 -0
- package/bin/expand_home_directory_tokens.mjs +4 -5
- package/bin/expand_home_directory_tokens.test.mjs +128 -0
- package/bin/git_hooks_installer.mjs +22 -3
- package/bin/git_hooks_installer.test.mjs +159 -5
- package/bin/install-session-continuity.mjs +88 -0
- package/bin/install-session-continuity.test.mjs +55 -0
- package/bin/install.codex-rules.test.mjs +5 -2
- package/bin/install.cursor-rules.test.mjs +5 -2
- package/bin/install.mjs +478 -63
- package/bin/install.policy-lint.test.mjs +56 -4
- package/bin/install.profiles.test.mjs +10 -0
- package/bin/install.prune.test.mjs +227 -17
- package/bin/install.test.mjs +500 -120
- package/bin/install.transaction.test.mjs +3 -3
- package/docs/CODE_RULES.md +1 -1
- package/hooks/blocking/code_rules_banned_identifiers.py +3 -0
- package/hooks/blocking/precommit_code_rules_gate.py +5 -8
- package/hooks/blocking/test_bash_dispatcher_interpreter_starts.py +7 -52
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -69
- package/hooks/blocking/test_code_rules_banned_identifiers.py +64 -0
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +10 -244
- package/hooks/blocking/test_shared_stdin_adoption.py +1 -47
- package/hooks/git-hooks/git_hooks_constants/verification_notice_constants.py +116 -0
- package/hooks/git-hooks/post_commit.py +33 -0
- package/hooks/git-hooks/pre_commit.py +18 -90
- package/hooks/git-hooks/pre_push.py +45 -541
- package/hooks/git-hooks/test_installed_policy_lint.py +55 -6
- package/hooks/git-hooks/test_native_hook_support.py +129 -0
- package/hooks/git-hooks/test_native_policy_commit.py +39 -6
- package/hooks/git-hooks/test_post_commit.py +43 -0
- package/hooks/git-hooks/test_pre_commit.py +36 -392
- package/hooks/git-hooks/test_pre_push.py +48 -1631
- package/hooks/git-hooks/test_verification_notice.py +60 -0
- package/hooks/git-hooks/test_verification_start.py +173 -0
- package/hooks/git-hooks/verification_notice.py +192 -0
- package/hooks/git-hooks/verification_notice_context.py +253 -0
- package/hooks/git-hooks/verification_notice_state.py +237 -0
- package/hooks/git-hooks/verification_start.py +100 -0
- package/hooks/hooks.json +0 -119
- package/hooks/hooks_constants/banned_identifiers_constants.py +4 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -15
- package/hooks/hooks_constants/post_tool_use_dispatcher_constants.py +0 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -50
- package/hooks/hooks_constants/stop_dispatcher_constants.py +1 -4
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +3 -66
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +42 -185
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +3 -25
- package/hooks/validation/test_post_tool_use_dispatcher.py +35 -298
- package/hooks/validators/run_all_validators.py +5 -1
- package/hooks/validators/security_checks.py +57 -2
- package/hooks/validators/test_hook_timing_harness.py +2 -5
- package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +3 -11
- package/hooks/validators/test_run_all_validators_policy_lint.py +36 -0
- package/hooks/validators/test_security_checks.py +57 -0
- package/package.json +1 -1
- package/rules/confirm-implementation-forks.md +2 -0
- package/rules/git-workflow.md +12 -0
- package/rules/pstack-host-mapping.md +48 -0
- package/rules/vault-context.md +3 -5
- package/rules/verify-runtime-state.md +7 -0
- package/scripts/audit-hooks-cli.mjs +5 -0
- package/scripts/audit-hooks.mjs +72 -16
- package/scripts/automatic_advisory/__init__.py +1 -0
- package/scripts/automatic_advisory/checkout.py +51 -0
- package/scripts/automatic_advisory/cli.py +334 -0
- package/scripts/automatic_advisory/config/__init__.py +1 -0
- package/scripts/automatic_advisory/config/constants.py +93 -0
- package/scripts/automatic_advisory/config/timing.py +4 -0
- package/scripts/automatic_advisory/configuration.py +197 -0
- package/scripts/automatic_advisory/execution.py +350 -0
- package/scripts/automatic_advisory/git.py +69 -0
- package/scripts/automatic_advisory/model.py +107 -0
- package/scripts/automatic_advisory/process_host.py +32 -0
- package/scripts/automatic_advisory/publisher.py +84 -0
- package/scripts/automatic_advisory/runner.py +399 -0
- package/scripts/automatic_advisory/state.py +116 -0
- package/scripts/automatic_advisory/window_flags.py +41 -0
- package/scripts/automatic_advisory/windows_job.py +122 -0
- package/scripts/codex_compat_materializer.py +99 -13
- package/scripts/durable_post_lint.py +30 -2
- package/scripts/durable_post_lint_config/config/constants.py +10 -0
- package/scripts/durable_post_lint_config/config/test_constants.py +13 -0
- package/scripts/local_report_cli.py +184 -0
- package/scripts/local_report_core.py +247 -0
- package/scripts/local_report_publisher.py +12 -0
- package/scripts/local_report_validation.py +357 -0
- package/scripts/local_verification/__init__.py +23 -0
- package/scripts/local_verification/check_runner.py +364 -0
- package/scripts/local_verification/check_support.py +99 -0
- package/scripts/local_verification/cli.py +158 -0
- package/scripts/local_verification/command_runner.py +84 -0
- package/scripts/local_verification/config/__init__.py +1 -0
- package/scripts/local_verification/config/constants.py +89 -0
- package/scripts/local_verification/config/timing.py +1 -0
- package/scripts/local_verification/git_state.py +146 -0
- package/scripts/local_verification/manifest.py +224 -0
- package/scripts/local_verification/model.py +174 -0
- package/scripts/local_verification/runner.py +234 -0
- package/scripts/policy_lint/adapter_pairing.py +58 -3
- package/scripts/policy_lint/config/approved_test_pairs.py +323 -0
- package/scripts/pr_verification/__init__.py +1 -0
- package/scripts/pr_verification/branch_refs.py +114 -0
- package/scripts/pr_verification/config/__init__.py +1 -0
- package/scripts/pr_verification/config/constants.py +84 -0
- package/scripts/pr_verification/config/timing.py +5 -0
- package/scripts/pr_verification/github.py +137 -0
- package/scripts/pr_verification/github_api.py +376 -0
- package/scripts/pr_verification/github_labels.py +88 -0
- package/scripts/pr_verification/github_parsing.py +182 -0
- package/scripts/pr_verification/github_transport.py +91 -0
- package/scripts/pr_verification/lock.py +122 -0
- package/scripts/pr_verification/model.py +43 -0
- package/scripts/pr_verification/requirements.txt +1 -0
- package/scripts/test_durable_post_lint.py +97 -0
- package/scripts/tests/test-audit-hooks-cli.mjs +150 -0
- package/scripts/tests/test-audit-hooks.mjs +94 -2
- package/scripts/tests/test_automatic_advisory.py +376 -0
- package/scripts/tests/test_automatic_advisory_job_cleanup.py +71 -0
- package/scripts/tests/test_automatic_advisory_polling.py +216 -0
- package/scripts/tests/test_automatic_advisory_recovery.py +320 -0
- package/scripts/tests/test_automatic_advisory_repairs.py +400 -0
- package/scripts/tests/test_automatic_advisory_settings.py +48 -0
- package/scripts/tests/test_automatic_advisory_window_flags.py +163 -0
- package/scripts/tests/test_closed_pr_label.py +157 -0
- package/scripts/tests/test_codex_compat_materializer.py +189 -6
- package/scripts/tests/test_github_labels.py +101 -0
- package/scripts/tests/test_github_transport.py +125 -0
- package/scripts/tests/test_local_report_publisher.py +396 -0
- package/scripts/tests/test_local_report_publisher_cli.py +85 -0
- package/scripts/tests/test_local_verification.py +348 -0
- package/scripts/tests/test_local_verification_cli.py +98 -0
- package/scripts/tests/test_policy_lint_rules_pairing.py +163 -0
- package/scripts/tests/test_pr_verification_github.py +392 -0
- package/scripts/tests/test_pr_verification_lock.py +168 -0
- package/scripts/tests/test_verification_notice.py +347 -0
- package/hooks/blocking/ask_user_question_shape_blocker.py +0 -83
- package/hooks/blocking/bot_mention_comment_blocker.py +0 -76
- package/hooks/blocking/claude_md_orphan_file_blocker.py +0 -129
- package/hooks/blocking/cursor_cli_python_misfire_blocker.py +0 -108
- package/hooks/blocking/destructive_command_blocker.py +0 -2073
- package/hooks/blocking/fable_spawn_gate.py +0 -187
- package/hooks/blocking/luna_fast_mode_gate.py +0 -160
- package/hooks/blocking/nas_ssh_binary_enforcer.py +0 -163
- package/hooks/blocking/orchestrator_refresh_reschedule_gate.py +0 -260
- package/hooks/blocking/package_inventory_stale_blocker.py +0 -107
- package/hooks/blocking/pii_prevention_blocker.py +0 -365
- package/hooks/blocking/piped_pytest_blocker.py +0 -1223
- package/hooks/blocking/question_to_user_enforcer.py +0 -130
- package/hooks/blocking/send_user_file_open_locally_blocker.py +0 -71
- package/hooks/blocking/sensitive_file_protector.py +0 -167
- package/hooks/blocking/session_edit_stage_gate.py +0 -684
- package/hooks/blocking/session_handoff_blocker.py +0 -182
- package/hooks/blocking/shell_substitution_blocker.py +0 -350
- package/hooks/blocking/test_ask_user_question_shape.py +0 -69
- package/hooks/blocking/test_bot_mention_comment_blocker.py +0 -131
- package/hooks/blocking/test_claude_md_orphan_file_blocker.py +0 -1277
- package/hooks/blocking/test_cursor_cli_python_misfire_blocker.py +0 -208
- package/hooks/blocking/test_destructive_command_blocker.py +0 -2024
- package/hooks/blocking/test_destructive_command_blocker_deny_mode.py +0 -105
- package/hooks/blocking/test_destructive_command_blocker_no_verify.py +0 -122
- package/hooks/blocking/test_destructive_command_blocker_patterns.py +0 -133
- package/hooks/blocking/test_fable_spawn_gate.py +0 -381
- package/hooks/blocking/test_inventory_deadlock_resolution.py +0 -154
- package/hooks/blocking/test_luna_fast_mode_gate.py +0 -211
- package/hooks/blocking/test_nas_ssh_binary_enforcer.py +0 -225
- package/hooks/blocking/test_orchestrator_refresh_reschedule_gate.py +0 -231
- package/hooks/blocking/test_package_inventory_stale_blocker.py +0 -382
- package/hooks/blocking/test_pii_prevention_blocker.py +0 -1222
- package/hooks/blocking/test_pii_prevention_windows_git_path.py +0 -41
- package/hooks/blocking/test_pii_write_surface_ephemeral.py +0 -221
- package/hooks/blocking/test_piped_pytest_blocker.py +0 -587
- package/hooks/blocking/test_precommit_code_rules_gate_native_owner.py +0 -233
- package/hooks/blocking/test_question_to_user_enforcer.py +0 -169
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +0 -118
- package/hooks/blocking/test_sensitive_file_protector.py +0 -365
- package/hooks/blocking/test_session_edit_stage_gate.py +0 -537
- package/hooks/blocking/test_session_edit_stage_gate_staging.py +0 -64
- package/hooks/blocking/test_session_edit_stage_gate_support.py +0 -204
- package/hooks/blocking/test_session_edit_stage_gate_tracker.py +0 -116
- package/hooks/blocking/test_session_handoff_blocker.py +0 -318
- package/hooks/blocking/test_shell_substitution_blocker.py +0 -298
- package/hooks/blocking/test_unscoped_search_blocker.py +0 -102
- package/hooks/blocking/test_write_existing_file_blocker.py +0 -122
- package/hooks/blocking/tests/test_pii_prevention_blocker.py +0 -259
- package/hooks/blocking/unscoped_search_blocker.py +0 -391
- package/hooks/blocking/write_existing_file_blocker.py +0 -124
- package/hooks/hooks_constants/bot_mention_comment_blocker_constants.py +0 -19
- package/hooks/hooks_constants/cursor_cli_python_misfire_blocker_constants.py +0 -79
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +0 -61
- package/hooks/hooks_constants/luna_fast_mode_gate_constants.py +0 -45
- package/hooks/hooks_constants/nas_ssh_binary_enforcer_constants.py +0 -20
- package/hooks/hooks_constants/orchestrator_refresh_reschedule_gate_constants.py +0 -57
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +0 -17
- package/hooks/hooks_constants/sensitive_file_protector_constants.py +0 -40
- package/hooks/hooks_constants/session_handoff_blocker_constants.py +0 -10
- package/hooks/hooks_constants/shell_substitution_blocker_constants.py +0 -80
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse gate: deny a fable-tier subagent spawn that carries no marker.
|
|
3
|
-
|
|
4
|
-
One exact token in the spawn prompt authorizes a spawn at the fable tier::
|
|
5
|
-
|
|
6
|
-
model: fable, no FABLE-SPAWN-AUTHORIZED flag: deny
|
|
7
|
-
model: claude-fable-5, no FABLE-SPAWN-AUTHORIZED flag: deny
|
|
8
|
-
model: fable, FABLE-SPAWN-AUTHORIZED ok: allow
|
|
9
|
-
model: claude-sonnet-4, prompt either way ok: allow
|
|
10
|
-
no model field, prompt either way ok: allow
|
|
11
|
-
|
|
12
|
-
The gate reads an ``Agent`` or ``Task`` call and finds the tier in the model
|
|
13
|
-
field whether that field carries the bare alias or a full model id, in any
|
|
14
|
-
letter case. Its deny reason points at the advisor-protocol document rather
|
|
15
|
-
than quoting the token, so a denial pasted into a retry authorizes nothing.
|
|
16
|
-
A deny also sets ``additionalContext`` so the spawner sees the recovery path
|
|
17
|
-
(re-spawn at opus / an opus-equivalent tier, or authorize a fable bind).
|
|
18
|
-
"""
|
|
19
|
-
|
|
20
|
-
from __future__ import annotations
|
|
21
|
-
|
|
22
|
-
import json
|
|
23
|
-
import sys
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
from typing import Mapping, TextIO
|
|
26
|
-
|
|
27
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
28
|
-
if _hooks_dir not in sys.path:
|
|
29
|
-
sys.path.insert(0, _hooks_dir)
|
|
30
|
-
|
|
31
|
-
from hooks_constants.fable_spawn_gate_constants import ( # noqa: E402
|
|
32
|
-
ALL_SPAWN_TOOL_NAMES,
|
|
33
|
-
CALLING_HOOK_NAME,
|
|
34
|
-
DENY_ADDITIONAL_CONTEXT,
|
|
35
|
-
DENY_PREVIEW_TEMPLATE,
|
|
36
|
-
DENY_REASON,
|
|
37
|
-
FABLE_MODEL_ALIAS,
|
|
38
|
-
FABLE_SPAWN_AUTHORIZATION_MARKER,
|
|
39
|
-
HOOK_EVENT_NAME,
|
|
40
|
-
MAXIMUM_PREVIEW_MODEL_LENGTH,
|
|
41
|
-
MODEL_FIELD_NAME,
|
|
42
|
-
MODEL_SEGMENT_SPLIT_PATTERN,
|
|
43
|
-
PROMPT_FIELD_NAME,
|
|
44
|
-
TOOL_INPUT_FIELD_NAME,
|
|
45
|
-
TOOL_NAME_FIELD_NAME,
|
|
46
|
-
)
|
|
47
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
48
|
-
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
49
|
-
read_hook_input_dictionary_from_stdin,
|
|
50
|
-
)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _model_names_the_fable_tier(model_identifier: str) -> bool:
|
|
54
|
-
"""Report whether a model string names the fable tier.
|
|
55
|
-
|
|
56
|
-
The field carries either the bare alias or a full model id, so the
|
|
57
|
-
lowercased string is cut on its delimiters and the tier is looked for as
|
|
58
|
-
a whole segment::
|
|
59
|
-
|
|
60
|
-
fable -> ['fable'] flag: fable tier
|
|
61
|
-
claude-fable-5 -> ['claude', 'fable', '5'] flag: fable tier
|
|
62
|
-
claude-sonnet-4 -> ['claude', 'sonnet', '4'] ok: another tier
|
|
63
|
-
affable -> ['affable'] ok: another tier
|
|
64
|
-
|
|
65
|
-
Args:
|
|
66
|
-
model_identifier: The spawn's ``model`` string.
|
|
67
|
-
|
|
68
|
-
Returns:
|
|
69
|
-
True when a model segment reads ``fable`` in any letter case; False
|
|
70
|
-
for another tier.
|
|
71
|
-
"""
|
|
72
|
-
all_segments = MODEL_SEGMENT_SPLIT_PATTERN.split(model_identifier.strip().lower())
|
|
73
|
-
return FABLE_MODEL_ALIAS in all_segments
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _prompt_carries_authorization_marker(all_tool_input: Mapping[str, object]) -> bool:
|
|
77
|
-
"""Report whether the spawn prompt holds the exact authorization token.
|
|
78
|
-
|
|
79
|
-
Args:
|
|
80
|
-
all_tool_input: The spawn's ``tool_input`` mapping.
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
True when the prompt is a string holding the marker token; False for
|
|
84
|
-
an unmarked prompt and for an absent or non-string field.
|
|
85
|
-
"""
|
|
86
|
-
spawn_prompt = all_tool_input.get(PROMPT_FIELD_NAME)
|
|
87
|
-
if not isinstance(spawn_prompt, str):
|
|
88
|
-
return False
|
|
89
|
-
return FABLE_SPAWN_AUTHORIZATION_MARKER in spawn_prompt
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def _denied_spawn_model(all_payload_by_field: Mapping[str, object]) -> str | None:
|
|
93
|
-
"""Return the model string of an unauthorized fable spawn, or None.
|
|
94
|
-
|
|
95
|
-
The model string travels back as the decision, so the deny path holds a
|
|
96
|
-
proven fable string and never re-derives one.
|
|
97
|
-
|
|
98
|
-
Args:
|
|
99
|
-
all_payload_by_field: The parsed PreToolUse payload, keyed by
|
|
100
|
-
top-level field name.
|
|
101
|
-
|
|
102
|
-
Returns:
|
|
103
|
-
The ``model`` string for a spawn tool call at the fable tier whose
|
|
104
|
-
prompt lacks the authorization marker; None for every allowed call.
|
|
105
|
-
"""
|
|
106
|
-
if all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "") not in ALL_SPAWN_TOOL_NAMES:
|
|
107
|
-
return None
|
|
108
|
-
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
109
|
-
if not isinstance(tool_input, dict):
|
|
110
|
-
return None
|
|
111
|
-
model_identifier = tool_input.get(MODEL_FIELD_NAME)
|
|
112
|
-
if not isinstance(model_identifier, str):
|
|
113
|
-
return None
|
|
114
|
-
if not _model_names_the_fable_tier(model_identifier):
|
|
115
|
-
return None
|
|
116
|
-
if _prompt_carries_authorization_marker(tool_input):
|
|
117
|
-
return None
|
|
118
|
-
return model_identifier
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _build_denial_preview(model_identifier: str) -> str:
|
|
122
|
-
"""Build the bounded preview the hook-blocks log records for a denial.
|
|
123
|
-
|
|
124
|
-
The preview names the model field that tripped the gate, so its length
|
|
125
|
-
holds steady whatever the spawn prompt carries::
|
|
126
|
-
|
|
127
|
-
fable -> model=fable marker_present=False
|
|
128
|
-
claude-fable-5 -> model=claude-fable-5 marker_present=False
|
|
129
|
-
|
|
130
|
-
Args:
|
|
131
|
-
model_identifier: The fable model string the denial was decided on.
|
|
132
|
-
|
|
133
|
-
Returns:
|
|
134
|
-
One preview line naming the model text and the marker state.
|
|
135
|
-
"""
|
|
136
|
-
return DENY_PREVIEW_TEMPLATE.format(
|
|
137
|
-
model_text=model_identifier[:MAXIMUM_PREVIEW_MODEL_LENGTH]
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _emit_denial(
|
|
142
|
-
decision_stream: TextIO,
|
|
143
|
-
all_payload_by_field: Mapping[str, object],
|
|
144
|
-
model_identifier: str,
|
|
145
|
-
) -> None:
|
|
146
|
-
"""Log the denied spawn and write the PreToolUse deny payload.
|
|
147
|
-
|
|
148
|
-
Args:
|
|
149
|
-
decision_stream: Writable text stream — production code passes
|
|
150
|
-
``sys.stdout``; tests pass a ``StringIO`` to capture the JSON.
|
|
151
|
-
all_payload_by_field: The parsed PreToolUse payload, whose tool name
|
|
152
|
-
names the denied spawn in the hook-blocks log.
|
|
153
|
-
model_identifier: The fable model string the denial was decided on.
|
|
154
|
-
"""
|
|
155
|
-
denial = {
|
|
156
|
-
"hookSpecificOutput": {
|
|
157
|
-
"hookEventName": HOOK_EVENT_NAME,
|
|
158
|
-
"permissionDecision": "deny",
|
|
159
|
-
"permissionDecisionReason": DENY_REASON,
|
|
160
|
-
"additionalContext": DENY_ADDITIONAL_CONTEXT,
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
log_hook_block(
|
|
164
|
-
calling_hook_name=CALLING_HOOK_NAME,
|
|
165
|
-
hook_event=HOOK_EVENT_NAME,
|
|
166
|
-
block_reason=DENY_REASON,
|
|
167
|
-
tool_name=str(all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")),
|
|
168
|
-
offending_input_preview=_build_denial_preview(model_identifier),
|
|
169
|
-
)
|
|
170
|
-
decision_stream.write(json.dumps(denial) + "\n")
|
|
171
|
-
decision_stream.flush()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def main() -> None:
|
|
175
|
-
"""Read the PreToolUse payload and deny an unmarked fable spawn."""
|
|
176
|
-
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
177
|
-
if hook_payload is None:
|
|
178
|
-
sys.exit(0)
|
|
179
|
-
denied_model = _denied_spawn_model(hook_payload)
|
|
180
|
-
if denied_model is None:
|
|
181
|
-
sys.exit(0)
|
|
182
|
-
_emit_denial(sys.stdout, hook_payload, denied_model)
|
|
183
|
-
sys.exit(0)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if __name__ == "__main__":
|
|
187
|
-
main()
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse gate: deny Luna spawns that use an unsupported service tier."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import json
|
|
7
|
-
import sys
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Mapping, TextIO
|
|
10
|
-
|
|
11
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
12
|
-
if _hooks_dir not in sys.path:
|
|
13
|
-
sys.path.insert(0, _hooks_dir)
|
|
14
|
-
|
|
15
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
16
|
-
from hooks_constants.luna_fast_mode_gate_constants import ( # noqa: E402
|
|
17
|
-
ALL_SPAWN_TOOL_NAMES,
|
|
18
|
-
CALLING_HOOK_NAME,
|
|
19
|
-
CODEX_AGENT_TOOL_NAME,
|
|
20
|
-
DENY_ADDITIONAL_CONTEXT,
|
|
21
|
-
DENY_PREVIEW_TEMPLATE,
|
|
22
|
-
DENY_REASON,
|
|
23
|
-
FAST_SERVICE_TIER,
|
|
24
|
-
HOOK_EVENT_NAME,
|
|
25
|
-
LUNA_MODEL_ALIAS,
|
|
26
|
-
MAXIMUM_PREVIEW_FIELD_LENGTH,
|
|
27
|
-
MODEL_FIELD_NAME,
|
|
28
|
-
MODEL_SEGMENT_SPLIT_PATTERN,
|
|
29
|
-
PRIORITY_SERVICE_TIER,
|
|
30
|
-
SERVICE_TIER_FIELD_NAME,
|
|
31
|
-
TOOL_INPUT_FIELD_NAME,
|
|
32
|
-
TOOL_NAME_FIELD_NAME,
|
|
33
|
-
)
|
|
34
|
-
from hooks_constants.pre_tool_use_stdin import ( # noqa: E402
|
|
35
|
-
read_hook_input_dictionary_from_stdin,
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _model_names_the_luna_tier(model_identifier: str) -> bool:
|
|
40
|
-
"""Report whether a model string names the Luna tier.
|
|
41
|
-
|
|
42
|
-
Args:
|
|
43
|
-
model_identifier: The spawn's ``model`` string.
|
|
44
|
-
|
|
45
|
-
Returns:
|
|
46
|
-
True when a model segment reads ``luna`` in any letter case; False
|
|
47
|
-
for another tier.
|
|
48
|
-
"""
|
|
49
|
-
all_segments = MODEL_SEGMENT_SPLIT_PATTERN.split(model_identifier.strip().lower())
|
|
50
|
-
return LUNA_MODEL_ALIAS in all_segments
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _is_service_tier_allowed(tool_name: object, service_tier: object) -> bool:
|
|
54
|
-
if service_tier == FAST_SERVICE_TIER:
|
|
55
|
-
return True
|
|
56
|
-
return (
|
|
57
|
-
tool_name == CODEX_AGENT_TOOL_NAME
|
|
58
|
-
and service_tier == PRIORITY_SERVICE_TIER
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _denied_spawn_details(
|
|
63
|
-
all_payload_by_field: Mapping[str, object],
|
|
64
|
-
) -> tuple[str, object] | None:
|
|
65
|
-
"""Return model and service tier for a Luna spawn that must be denied.
|
|
66
|
-
|
|
67
|
-
Args:
|
|
68
|
-
all_payload_by_field: The parsed PreToolUse payload.
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
The model and service tier for an invalid Luna spawn; None otherwise.
|
|
72
|
-
"""
|
|
73
|
-
tool_name = all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")
|
|
74
|
-
if tool_name not in ALL_SPAWN_TOOL_NAMES:
|
|
75
|
-
return None
|
|
76
|
-
tool_input = all_payload_by_field.get(TOOL_INPUT_FIELD_NAME, {})
|
|
77
|
-
if not isinstance(tool_input, dict):
|
|
78
|
-
return None
|
|
79
|
-
model_identifier = tool_input.get(MODEL_FIELD_NAME)
|
|
80
|
-
if not isinstance(model_identifier, str):
|
|
81
|
-
return None
|
|
82
|
-
if not _model_names_the_luna_tier(model_identifier):
|
|
83
|
-
return None
|
|
84
|
-
service_tier = tool_input.get(SERVICE_TIER_FIELD_NAME)
|
|
85
|
-
if _is_service_tier_allowed(tool_name, service_tier):
|
|
86
|
-
return None
|
|
87
|
-
return model_identifier, service_tier
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def _build_denial_preview(model_identifier: str, service_tier: object) -> str:
|
|
91
|
-
"""Build a bounded preview for the block log without recording the prompt.
|
|
92
|
-
|
|
93
|
-
Args:
|
|
94
|
-
model_identifier: The Luna model string.
|
|
95
|
-
service_tier: The supplied service tier value.
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
A bounded preview naming only the model and service tier fields.
|
|
99
|
-
"""
|
|
100
|
-
model_text = model_identifier[:MAXIMUM_PREVIEW_FIELD_LENGTH]
|
|
101
|
-
service_tier_text = str(service_tier)[:MAXIMUM_PREVIEW_FIELD_LENGTH]
|
|
102
|
-
return DENY_PREVIEW_TEMPLATE.format(
|
|
103
|
-
model_text=model_text,
|
|
104
|
-
service_tier_text=service_tier_text,
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _emit_denial(
|
|
109
|
-
decision_stream: TextIO,
|
|
110
|
-
all_payload_by_field: Mapping[str, object],
|
|
111
|
-
model_identifier: str,
|
|
112
|
-
service_tier: object,
|
|
113
|
-
) -> None:
|
|
114
|
-
"""Log the denied spawn and write the PreToolUse deny payload.
|
|
115
|
-
|
|
116
|
-
Args:
|
|
117
|
-
decision_stream: Writable stream for the JSON decision.
|
|
118
|
-
all_payload_by_field: The parsed PreToolUse payload.
|
|
119
|
-
model_identifier: The denied Luna model string.
|
|
120
|
-
service_tier: The invalid service tier value.
|
|
121
|
-
"""
|
|
122
|
-
denial = {
|
|
123
|
-
"hookSpecificOutput": {
|
|
124
|
-
"hookEventName": HOOK_EVENT_NAME,
|
|
125
|
-
"permissionDecision": "deny",
|
|
126
|
-
"permissionDecisionReason": DENY_REASON,
|
|
127
|
-
"additionalContext": DENY_ADDITIONAL_CONTEXT,
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
log_hook_block(
|
|
131
|
-
calling_hook_name=CALLING_HOOK_NAME,
|
|
132
|
-
hook_event=HOOK_EVENT_NAME,
|
|
133
|
-
block_reason=DENY_REASON,
|
|
134
|
-
tool_name=str(all_payload_by_field.get(TOOL_NAME_FIELD_NAME, "")),
|
|
135
|
-
offending_input_preview=_build_denial_preview(model_identifier, service_tier),
|
|
136
|
-
)
|
|
137
|
-
decision_stream.write(json.dumps(denial) + "\n")
|
|
138
|
-
decision_stream.flush()
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def main() -> None:
|
|
142
|
-
"""Read the PreToolUse payload and deny an invalid Luna spawn."""
|
|
143
|
-
hook_payload = read_hook_input_dictionary_from_stdin()
|
|
144
|
-
if hook_payload is None:
|
|
145
|
-
sys.exit(0)
|
|
146
|
-
denied_details = _denied_spawn_details(hook_payload)
|
|
147
|
-
if denied_details is None:
|
|
148
|
-
sys.exit(0)
|
|
149
|
-
denied_model, denied_service_tier = denied_details
|
|
150
|
-
_emit_denial(
|
|
151
|
-
sys.stdout,
|
|
152
|
-
hook_payload,
|
|
153
|
-
denied_model,
|
|
154
|
-
denied_service_tier,
|
|
155
|
-
)
|
|
156
|
-
sys.exit(0)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if __name__ == "__main__":
|
|
160
|
-
main()
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook: force the Windows OpenSSH binary for NAS ssh/scp/sftp commands.
|
|
3
|
-
|
|
4
|
-
Root cause: Git Bash's MSYS ssh reads ``~/.ssh/id_ed25519`` as world-readable
|
|
5
|
-
through its ACL mapping, rejects the key as "bad permissions", offers no key, and
|
|
6
|
-
falls back to an interactive password prompt that hangs unattended agent sessions.
|
|
7
|
-
The Windows OpenSSH binary under ``System32/OpenSSH`` authenticates the same key
|
|
8
|
-
without prompting.
|
|
9
|
-
|
|
10
|
-
Detection strategy: this hook fires only on the Bash tool. The command is
|
|
11
|
-
shlex-tokenized (quotes removed), glued shell control operators are exploded off
|
|
12
|
-
the tokens, and the tokens are split into simple-command segments on ``&&`` /
|
|
13
|
-
``||`` / ``;`` / ``|&`` / ``|`` / ``&``. For each segment the effective leading program is
|
|
14
|
-
found by skipping ``VAR=value`` assignments and launcher wrappers (``timeout``,
|
|
15
|
-
``nohup``, ``nice``, ``stdbuf``, ``setsid``, ``env``) with their flags and duration
|
|
16
|
-
arguments. A segment is evaluated only when its leading program's basename is
|
|
17
|
-
``ssh``/``scp``/``sftp`` (with or without a ``.exe`` suffix) AND a token in that same
|
|
18
|
-
segment holds the NAS host (resolved from the ``CLAUDE_NAS_*`` environment variables or ``~/.claude/local-identity.json``) as a connection host — bare,
|
|
19
|
-
``user@host``, or a ``host:path`` target. The address inside a remote-command
|
|
20
|
-
argument or a remote path component on another host does not count.
|
|
21
|
-
|
|
22
|
-
An evaluated segment is DENIED with the binary-swap message when its leading program
|
|
23
|
-
is a bare ssh-family word rather than a path ending in ``OpenSSH/ssh.exe`` (or
|
|
24
|
-
``scp.exe`` / ``sftp.exe``). An evaluated segment whose leading program is that full
|
|
25
|
-
OpenSSH path is DENIED with the batch-mode message when no token in the segment
|
|
26
|
-
carries ``BatchMode=yes``, so an authentication regression fails loudly rather than
|
|
27
|
-
prompting. Everything else is allowed: ssh to any other host, the full OpenSSH path
|
|
28
|
-
with ``BatchMode=yes``, non-Bash tools, a command that only mentions the address
|
|
29
|
-
without an ssh-family leading program, and a command shlex cannot tokenize.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
import json
|
|
33
|
-
import re
|
|
34
|
-
import shlex
|
|
35
|
-
import sys
|
|
36
|
-
from pathlib import Path
|
|
37
|
-
|
|
38
|
-
_hooks_dir = str(Path(__file__).resolve().parent.parent)
|
|
39
|
-
if _hooks_dir not in sys.path:
|
|
40
|
-
sys.path.insert(0, _hooks_dir)
|
|
41
|
-
|
|
42
|
-
from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
|
|
43
|
-
from hooks_constants.local_identity import ( # noqa: E402
|
|
44
|
-
bare_ssh_binary_deny_message,
|
|
45
|
-
missing_batch_mode_deny_message,
|
|
46
|
-
nas_host,
|
|
47
|
-
)
|
|
48
|
-
from hooks_constants.nas_ssh_binary_enforcer_constants import ( # noqa: E402
|
|
49
|
-
ALL_OPENSSH_BINARY_PATH_SUFFIXES,
|
|
50
|
-
ALL_SSH_FAMILY_COMMAND_BASENAMES,
|
|
51
|
-
BASH_TOOL_NAME,
|
|
52
|
-
BATCH_MODE_PATTERN,
|
|
53
|
-
)
|
|
54
|
-
from hooks_constants.shell_command_segments import ( # noqa: E402
|
|
55
|
-
effective_leading_program,
|
|
56
|
-
split_into_segments,
|
|
57
|
-
token_basename,
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def _is_openssh_binary_path(token: str) -> bool:
|
|
62
|
-
normalized_token = token.replace("\\", "/").lower()
|
|
63
|
-
return any(
|
|
64
|
-
normalized_token.endswith(each_suffix)
|
|
65
|
-
for each_suffix in ALL_OPENSSH_BINARY_PATH_SUFFIXES
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
def _nas_host_token_pattern() -> re.Pattern[str]:
|
|
70
|
-
return re.compile(rf"(?:^|@){re.escape(nas_host())}(?::|$)")
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def _segment_references_nas(
|
|
74
|
-
all_segment_tokens: list[str], nas_host_pattern: re.Pattern[str]
|
|
75
|
-
) -> bool:
|
|
76
|
-
return any(nas_host_pattern.search(each_token) for each_token in all_segment_tokens)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _segment_carries_batch_mode(all_segment_tokens: list[str]) -> bool:
|
|
80
|
-
return any(BATCH_MODE_PATTERN.search(each_token) for each_token in all_segment_tokens)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _find_nas_ssh_violation(command: str) -> str | None:
|
|
84
|
-
"""Return the deny message for a NAS ssh-family command, or None to allow.
|
|
85
|
-
|
|
86
|
-
::
|
|
87
|
-
|
|
88
|
-
ssh -p 22 operator@nas.example.local "ls" flag: bare-binary message
|
|
89
|
-
"…/OpenSSH/ssh.exe" -p 22 operator@nas… flag: missing-batch-mode message
|
|
90
|
-
"…/OpenSSH/ssh.exe" -o BatchMode=yes …NAS ok: None
|
|
91
|
-
ssh operator@example.com "ls" ok: None
|
|
92
|
-
|
|
93
|
-
Tokenizes the command, splits it into simple-command segments, and evaluates
|
|
94
|
-
each segment whose leading program is an ssh-family word AND whose tokens carry
|
|
95
|
-
the NAS address. A bare ssh-family leader returns the binary-swap message; the
|
|
96
|
-
full OpenSSH path leader without a ``BatchMode=yes`` token returns the batch-mode
|
|
97
|
-
message. Returns None when shlex cannot tokenize the command.
|
|
98
|
-
|
|
99
|
-
Args:
|
|
100
|
-
command: The raw Bash command string from the tool input.
|
|
101
|
-
|
|
102
|
-
Returns:
|
|
103
|
-
The deny message string when a segment violates the NAS ssh policy, else None.
|
|
104
|
-
"""
|
|
105
|
-
try:
|
|
106
|
-
all_command_tokens = shlex.split(command)
|
|
107
|
-
except ValueError:
|
|
108
|
-
return None
|
|
109
|
-
nas_host_pattern = _nas_host_token_pattern()
|
|
110
|
-
for each_segment in split_into_segments(all_command_tokens):
|
|
111
|
-
leading_program = effective_leading_program(each_segment)
|
|
112
|
-
if leading_program is None:
|
|
113
|
-
continue
|
|
114
|
-
if token_basename(leading_program) not in ALL_SSH_FAMILY_COMMAND_BASENAMES:
|
|
115
|
-
continue
|
|
116
|
-
if not _segment_references_nas(each_segment, nas_host_pattern):
|
|
117
|
-
continue
|
|
118
|
-
if not _is_openssh_binary_path(leading_program):
|
|
119
|
-
return bare_ssh_binary_deny_message()
|
|
120
|
-
if not _segment_carries_batch_mode(each_segment):
|
|
121
|
-
return missing_batch_mode_deny_message()
|
|
122
|
-
return None
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def main() -> None:
|
|
126
|
-
try:
|
|
127
|
-
hook_input = json.load(sys.stdin)
|
|
128
|
-
except json.JSONDecodeError:
|
|
129
|
-
sys.exit(0)
|
|
130
|
-
|
|
131
|
-
tool_name = hook_input.get("tool_name", "")
|
|
132
|
-
if tool_name != BASH_TOOL_NAME:
|
|
133
|
-
sys.exit(0)
|
|
134
|
-
|
|
135
|
-
command = hook_input.get("tool_input", {}).get("command", "")
|
|
136
|
-
if not command:
|
|
137
|
-
sys.exit(0)
|
|
138
|
-
|
|
139
|
-
deny_reason = _find_nas_ssh_violation(command)
|
|
140
|
-
if deny_reason is None:
|
|
141
|
-
sys.exit(0)
|
|
142
|
-
|
|
143
|
-
deny_payload = {
|
|
144
|
-
"hookSpecificOutput": {
|
|
145
|
-
"hookEventName": "PreToolUse",
|
|
146
|
-
"permissionDecision": "deny",
|
|
147
|
-
"permissionDecisionReason": deny_reason,
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
log_hook_block(
|
|
151
|
-
calling_hook_name="nas_ssh_binary_enforcer.py",
|
|
152
|
-
hook_event="PreToolUse",
|
|
153
|
-
block_reason=deny_reason,
|
|
154
|
-
tool_name=tool_name,
|
|
155
|
-
offending_input_preview=command,
|
|
156
|
-
)
|
|
157
|
-
print(json.dumps(deny_payload))
|
|
158
|
-
sys.stdout.flush()
|
|
159
|
-
sys.exit(0)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if __name__ == "__main__":
|
|
163
|
-
main()
|