standard_id-google 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +112 -4
- data/lib/generators/standard_id/google/install/install_generator.rb +77 -0
- data/lib/generators/standard_id/google/install/templates/initializer.rb.erb +31 -0
- data/lib/standard_id/google/providers/google.rb +128 -105
- data/lib/standard_id/google/version.rb +1 -1
- data/lib/standard_id/google.rb +6 -1
- metadata +7 -24
- data/.claude/hooks/enforce-worktree.sh +0 -84
- data/.claude/settings.json +0 -24
- data/.claude/skills/publish-gem/SKILL.md +0 -211
- data/.claude/skills/start/SKILL.md +0 -185
- data/.claude/skills/worktree/SKILL.md +0 -143
- data/.editorconfig +0 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -8
- data/.ruby-version +0 -1
- data/AGENTS.md +0 -71
- data/CLAUDE.md +0 -26
- data/CODE_OF_CONDUCT.md +0 -132
- data/lib/standard_id/google/railtie.rb +0 -13
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
-
require "
|
|
2
|
+
require "net/http"
|
|
3
3
|
|
|
4
4
|
module StandardId
|
|
5
5
|
module Providers
|
|
@@ -7,12 +7,26 @@ module StandardId
|
|
|
7
7
|
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth".freeze
|
|
8
8
|
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token".freeze
|
|
9
9
|
USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v2/userinfo".freeze
|
|
10
|
+
# Google's documented tokeninfo endpoint, for both ID tokens and access
|
|
11
|
+
# tokens (developers.google.com/identity/sign-in/web/backend-auth,
|
|
12
|
+
# developers.google.com/identity/protocols/oauth2). The legacy
|
|
13
|
+
# www.googleapis.com/oauth2/v3/tokeninfo host is no longer used.
|
|
10
14
|
TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo".freeze
|
|
15
|
+
VALID_ISSUERS = ["accounts.google.com", "https://accounts.google.com"].freeze
|
|
11
16
|
DEFAULT_SCOPE = "openid email profile".freeze
|
|
12
17
|
AUTHORIZATION_PARAM_DEFAULTS = {
|
|
13
18
|
scope: DEFAULT_SCOPE
|
|
14
19
|
}.freeze
|
|
15
20
|
|
|
21
|
+
# Pre-0.5.0 install generators wired the fields to these variables.
|
|
22
|
+
# Still read, with a deprecation warning, when the canonical variable
|
|
23
|
+
# (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) is unset and the host never
|
|
24
|
+
# assigns the field.
|
|
25
|
+
LEGACY_ENV = {
|
|
26
|
+
google_client_id: "GOOGLE_OAUTH_CLIENT_ID",
|
|
27
|
+
google_client_secret: "GOOGLE_OAUTH_CLIENT_SECRET"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
16
30
|
class << self
|
|
17
31
|
def provider_name
|
|
18
32
|
"google"
|
|
@@ -23,18 +37,12 @@ module StandardId
|
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def authorization_url(state:, redirect_uri:, **options)
|
|
26
|
-
|
|
40
|
+
build_authorization_url(
|
|
41
|
+
endpoint: AUTH_ENDPOINT,
|
|
27
42
|
client_id: credentials[:client_id],
|
|
28
|
-
redirect_uri
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
supported_authorization_params.each do |param|
|
|
34
|
-
query[param] = options[param] || AUTHORIZATION_PARAM_DEFAULTS[param]
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
"#{AUTH_ENDPOINT}?#{URI.encode_www_form(query.compact)}"
|
|
43
|
+
redirect_uri:, state:, options:,
|
|
44
|
+
defaults: AUTHORIZATION_PARAM_DEFAULTS
|
|
45
|
+
)
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
def get_user_info(code: nil, id_token: nil, access_token: nil, redirect_uri: nil, nonce: nil, **_options)
|
|
@@ -51,14 +59,25 @@ module StandardId
|
|
|
51
59
|
elsif code.present?
|
|
52
60
|
exchange_code_for_user_info(code: code, redirect_uri: redirect_uri, nonce: nonce)
|
|
53
61
|
else
|
|
54
|
-
raise StandardId::InvalidRequestError, "
|
|
62
|
+
raise StandardId::InvalidRequestError, "Google sign-in requires a code, an id_token or an access_token"
|
|
55
63
|
end
|
|
56
64
|
end
|
|
57
65
|
|
|
66
|
+
# `google_client_id` switches the provider on (it is the enabling
|
|
67
|
+
# field). `google_client_secret` is required while it is set: an
|
|
68
|
+
# enabled provider shows the web sign-in button, and the web flow's
|
|
69
|
+
# code exchange needs the secret — without it the user authenticates
|
|
70
|
+
# with Google and only then does the callback fail. The native
|
|
71
|
+
# id_token and access_token flows verify through Google's tokeninfo
|
|
72
|
+
# endpoint and need only the client ID.
|
|
73
|
+
#
|
|
74
|
+
# ENV fallbacks (standard_id >= 0.42): GOOGLE_CLIENT_ID and
|
|
75
|
+
# GOOGLE_CLIENT_SECRET; the pre-0.5.0 GOOGLE_OAUTH_* names are read as
|
|
76
|
+
# a deprecated fallback.
|
|
58
77
|
def config_schema
|
|
59
78
|
{
|
|
60
|
-
google_client_id: { type: :string, default:
|
|
61
|
-
google_client_secret: { type: :string, default:
|
|
79
|
+
google_client_id: { type: :string, default: -> { legacy_env(:google_client_id) } },
|
|
80
|
+
google_client_secret: { type: :string, default: -> { legacy_env(:google_client_secret) }, required: true }
|
|
62
81
|
}
|
|
63
82
|
end
|
|
64
83
|
|
|
@@ -67,139 +86,143 @@ module StandardId
|
|
|
67
86
|
end
|
|
68
87
|
|
|
69
88
|
def exchange_code_for_user_info(code:, redirect_uri:, nonce: nil)
|
|
70
|
-
|
|
89
|
+
rescue_to_oauth_error do
|
|
90
|
+
raise StandardId::InvalidRequestError, "Google authorization code is missing" if code.blank?
|
|
71
91
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
grant_type: "authorization_code",
|
|
77
|
-
redirect_uri: redirect_uri
|
|
78
|
-
}.compact)
|
|
79
|
-
|
|
80
|
-
unless token_response.is_a?(Net::HTTPSuccess)
|
|
81
|
-
raise StandardId::InvalidRequestError, "Failed to exchange Google authorization code"
|
|
82
|
-
end
|
|
92
|
+
creds = credentials
|
|
93
|
+
if creds[:client_secret].blank?
|
|
94
|
+
raise StandardId::InvalidRequestError, "Google OAuth credentials are incomplete: google_client_secret not set"
|
|
95
|
+
end
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
token_response = HttpClient.post_form(TOKEN_ENDPOINT, {
|
|
98
|
+
client_id: creds[:client_id],
|
|
99
|
+
client_secret: creds[:client_secret],
|
|
100
|
+
code: code,
|
|
101
|
+
grant_type: "authorization_code",
|
|
102
|
+
redirect_uri: redirect_uri
|
|
103
|
+
}.compact)
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
unless token_response.is_a?(Net::HTTPSuccess)
|
|
106
|
+
raise StandardId::InvalidRequestError,
|
|
107
|
+
"Failed to exchange Google authorization code: #{error_reason(token_response)}"
|
|
108
|
+
end
|
|
92
109
|
|
|
93
|
-
|
|
94
|
-
|
|
110
|
+
parsed_token = JSON.parse(token_response.body)
|
|
111
|
+
access_token = parsed_token["access_token"]
|
|
112
|
+
raise StandardId::InvalidRequestError, "Google token response is missing access_token" if access_token.blank?
|
|
95
113
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
# Web flow with a server-generated nonce: check it on the ID token.
|
|
115
|
+
if parsed_token["id_token"].present? && nonce.present?
|
|
116
|
+
verify_id_token(id_token: parsed_token["id_token"], nonce: nonce)
|
|
117
|
+
end
|
|
99
118
|
|
|
100
|
-
|
|
119
|
+
build_response(fetch_user_info(access_token: access_token), tokens: extract_tokens(parsed_token))
|
|
120
|
+
end
|
|
101
121
|
end
|
|
102
122
|
|
|
123
|
+
# Verifies through Google's tokeninfo endpoint, which checks the
|
|
124
|
+
# signature and expiry; the audience, issuer and nonce are checked
|
|
125
|
+
# here. Needs only the client ID.
|
|
103
126
|
def verify_id_token(id_token:, nonce: nil)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
|
|
127
|
+
rescue_to_oauth_error do
|
|
128
|
+
raise StandardId::InvalidRequestError, "Google id_token is missing" if id_token.blank?
|
|
107
129
|
|
|
108
|
-
|
|
130
|
+
response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, id_token: id_token)
|
|
131
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
|
|
109
132
|
|
|
110
|
-
|
|
133
|
+
token_info = JSON.parse(response.body)
|
|
111
134
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
token_nonce = token_info["nonce"]
|
|
115
|
-
if token_nonce != nonce
|
|
116
|
-
raise StandardId::InvalidRequestError,
|
|
117
|
-
"ID token nonce mismatch. Expected: #{nonce}, got: #{token_nonce}"
|
|
135
|
+
unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
|
|
136
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token audience"
|
|
118
137
|
end
|
|
119
|
-
end
|
|
120
138
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
end
|
|
139
|
+
unless VALID_ISSUERS.include?(token_info["iss"])
|
|
140
|
+
raise StandardId::InvalidRequestError, "Invalid Google ID token issuer"
|
|
141
|
+
end
|
|
125
142
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
143
|
+
# Constant-time, and the error never echoes either value.
|
|
144
|
+
verify_nonce!(expected: nonce, actual: token_info["nonce"])
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
"sub" => token_info["sub"],
|
|
148
|
+
"email" => token_info["email"],
|
|
149
|
+
"email_verified" => token_info["email_verified"],
|
|
150
|
+
"name" => token_info["name"],
|
|
151
|
+
"given_name" => token_info["given_name"],
|
|
152
|
+
"family_name" => token_info["family_name"],
|
|
153
|
+
"picture" => token_info["picture"],
|
|
154
|
+
"locale" => token_info["locale"]
|
|
155
|
+
}.compact
|
|
129
156
|
end
|
|
130
|
-
|
|
131
|
-
{
|
|
132
|
-
"sub" => token_info["sub"],
|
|
133
|
-
"email" => token_info["email"],
|
|
134
|
-
"email_verified" => token_info["email_verified"],
|
|
135
|
-
"name" => token_info["name"],
|
|
136
|
-
"given_name" => token_info["given_name"],
|
|
137
|
-
"family_name" => token_info["family_name"],
|
|
138
|
-
"picture" => token_info["picture"],
|
|
139
|
-
"locale" => token_info["locale"]
|
|
140
|
-
}.compact
|
|
141
|
-
rescue StandardError => e
|
|
142
|
-
raise e if e.is_a?(StandardId::OAuthError)
|
|
143
|
-
|
|
144
|
-
raise StandardId::OAuthError, e.message, cause: e
|
|
145
157
|
end
|
|
146
158
|
|
|
147
159
|
def fetch_user_info(access_token:)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
verify_token(access_token)
|
|
151
|
-
user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
|
|
160
|
+
rescue_to_oauth_error do
|
|
161
|
+
raise StandardId::InvalidRequestError, "Google access token is missing" if access_token.blank?
|
|
152
162
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
end
|
|
163
|
+
verify_token(access_token)
|
|
164
|
+
user_response = HttpClient.get_with_bearer(USERINFO_ENDPOINT, access_token)
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
166
|
+
unless user_response.is_a?(Net::HTTPSuccess)
|
|
167
|
+
raise StandardId::InvalidRequestError, "Failed to fetch Google user info: HTTP #{user_response.code}"
|
|
168
|
+
end
|
|
160
169
|
|
|
161
|
-
|
|
170
|
+
JSON.parse(user_response.body)
|
|
171
|
+
end
|
|
162
172
|
end
|
|
163
173
|
|
|
164
174
|
private
|
|
165
175
|
|
|
176
|
+
# The client ID every flow needs, and the secret only the code
|
|
177
|
+
# exchange needs (checked there). Raises when the provider is off.
|
|
166
178
|
def credentials
|
|
167
179
|
client_id = StandardId.config.google_client_id
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if client_id.blank? || client_secret.blank?
|
|
171
|
-
raise StandardId::InvalidRequestError, "Google provider is not configured"
|
|
172
|
-
end
|
|
180
|
+
raise StandardId::InvalidRequestError, "Google OAuth is not configured" if client_id.blank?
|
|
173
181
|
|
|
174
182
|
{
|
|
175
183
|
client_id: client_id,
|
|
176
|
-
client_secret:
|
|
184
|
+
client_secret: StandardId.config.google_client_secret
|
|
177
185
|
}
|
|
178
186
|
end
|
|
179
187
|
|
|
188
|
+
# Confirms an access token was issued to this app's client before its
|
|
189
|
+
# userinfo is trusted.
|
|
180
190
|
def verify_token(access_token)
|
|
181
|
-
response = HttpClient.post_form(
|
|
182
|
-
|
|
183
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
184
|
-
raise StandardId::InvalidRequestError, "Invalid or expired access token"
|
|
185
|
-
end
|
|
191
|
+
response = HttpClient.post_form(TOKEN_INFO_ENDPOINT, access_token: access_token)
|
|
192
|
+
raise StandardId::InvalidRequestError, "Invalid Google access token: invalid or expired" unless response.is_a?(Net::HTTPSuccess)
|
|
186
193
|
|
|
187
194
|
token_info = JSON.parse(response.body)
|
|
188
195
|
|
|
189
|
-
unless token_info["aud"] == credentials[:client_id]
|
|
190
|
-
raise StandardId::InvalidRequestError,
|
|
191
|
-
"Access token audience mismatch. Expected: #{credentials[:client_id]}, got: #{token_info["aud"]}"
|
|
196
|
+
unless token_info["aud"].present? && token_info["aud"] == credentials[:client_id]
|
|
197
|
+
raise StandardId::InvalidRequestError, "Invalid Google access token audience"
|
|
192
198
|
end
|
|
193
199
|
|
|
194
200
|
token_info
|
|
195
201
|
end
|
|
196
202
|
|
|
197
|
-
def
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}
|
|
203
|
+
def error_reason(response)
|
|
204
|
+
body = JSON.parse(response.body.to_s)
|
|
205
|
+
reason = body["error"] if body.is_a?(Hash)
|
|
206
|
+
reason.presence || "HTTP #{response.code}"
|
|
207
|
+
rescue JSON::ParserError
|
|
208
|
+
"HTTP #{response.code}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def legacy_env(field)
|
|
212
|
+
name = LEGACY_ENV.fetch(field)
|
|
213
|
+
value = ENV[name]
|
|
214
|
+
return nil if value.blank?
|
|
215
|
+
|
|
216
|
+
# An unassigned field's default is re-evaluated on read; warn once.
|
|
217
|
+
@legacy_env_warned ||= {}
|
|
218
|
+
return value if @legacy_env_warned[name]
|
|
219
|
+
|
|
220
|
+
@legacy_env_warned[name] = true
|
|
221
|
+
StandardId.deprecator.warn(
|
|
222
|
+
"standard_id-google: reading #{field} from #{name} is deprecated. " \
|
|
223
|
+
"Rename the variable to #{field.to_s.upcase} (or assign config.social.#{field} explicitly)."
|
|
224
|
+
)
|
|
225
|
+
value
|
|
203
226
|
end
|
|
204
227
|
end
|
|
205
228
|
end
|
data/lib/standard_id/google.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
require "active_support/core_ext/numeric/time"
|
|
2
2
|
require "active_support/core_ext/hash/indifferent_access"
|
|
3
3
|
require "standard_id"
|
|
4
|
+
require "standard_id/google/version"
|
|
4
5
|
require "standard_id/google/providers/google"
|
|
5
|
-
|
|
6
|
+
|
|
7
|
+
# Registers the provider from a Railtie's after_initialize (a no-op outside
|
|
8
|
+
# Rails). Its config fields are declared earlier, before config/initializers,
|
|
9
|
+
# by standard_id's own engine initializer.
|
|
10
|
+
StandardId::Providers.plugin_railtie(:google, "StandardId::Providers::Google")
|
metadata
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id-google
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
8
|
-
bindir:
|
|
8
|
+
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
@@ -29,20 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0.
|
|
33
|
-
- - ">="
|
|
34
|
-
- !ruby/object:Gem::Version
|
|
35
|
-
version: 0.1.7
|
|
32
|
+
version: '0.42'
|
|
36
33
|
type: :runtime
|
|
37
34
|
prerelease: false
|
|
38
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
39
36
|
requirements:
|
|
40
37
|
- - "~>"
|
|
41
38
|
- !ruby/object:Gem::Version
|
|
42
|
-
version: '0.
|
|
43
|
-
- - ">="
|
|
44
|
-
- !ruby/object:Gem::Version
|
|
45
|
-
version: 0.1.7
|
|
39
|
+
version: '0.42'
|
|
46
40
|
description: Extracted StandardId::Providers::Google implementation packaged as a
|
|
47
41
|
standalone gem so StandardId installations can opt into Sign in with Google independently.
|
|
48
42
|
email:
|
|
@@ -51,25 +45,14 @@ executables: []
|
|
|
51
45
|
extensions: []
|
|
52
46
|
extra_rdoc_files: []
|
|
53
47
|
files:
|
|
54
|
-
- ".claude/hooks/enforce-worktree.sh"
|
|
55
|
-
- ".claude/settings.json"
|
|
56
|
-
- ".claude/skills/publish-gem/SKILL.md"
|
|
57
|
-
- ".claude/skills/start/SKILL.md"
|
|
58
|
-
- ".claude/skills/worktree/SKILL.md"
|
|
59
|
-
- ".editorconfig"
|
|
60
|
-
- ".rspec"
|
|
61
|
-
- ".rubocop.yml"
|
|
62
|
-
- ".ruby-version"
|
|
63
|
-
- AGENTS.md
|
|
64
48
|
- CHANGELOG.md
|
|
65
|
-
-
|
|
66
|
-
- CODE_OF_CONDUCT.md
|
|
67
|
-
- LICENSE.txt
|
|
49
|
+
- LICENSE
|
|
68
50
|
- README.md
|
|
69
51
|
- Rakefile
|
|
52
|
+
- lib/generators/standard_id/google/install/install_generator.rb
|
|
53
|
+
- lib/generators/standard_id/google/install/templates/initializer.rb.erb
|
|
70
54
|
- lib/standard_id/google.rb
|
|
71
55
|
- lib/standard_id/google/providers/google.rb
|
|
72
|
-
- lib/standard_id/google/railtie.rb
|
|
73
56
|
- lib/standard_id/google/version.rb
|
|
74
57
|
homepage: https://github.com/rarebit-one/standard_id_google
|
|
75
58
|
licenses:
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
#!/bin/bash
|
|
2
|
-
# Enforce worktree-only file modifications for Claude Code
|
|
3
|
-
#
|
|
4
|
-
# Runs on PreToolUse for Edit, Write, and NotebookEdit tools.
|
|
5
|
-
# Blocks all file modifications in the main checkout — changes must
|
|
6
|
-
# happen inside a git worktree (.worktrees/<name>/).
|
|
7
|
-
#
|
|
8
|
-
# Exit codes:
|
|
9
|
-
# 0 — allow (in a worktree, CI, or non-git path)
|
|
10
|
-
# 2 — block (in main checkout)
|
|
11
|
-
#
|
|
12
|
-
# No programmatic bypass — this is intentional. If the hook itself has a bug,
|
|
13
|
-
# a human must fix it manually (edit the file or remove from settings.json).
|
|
14
|
-
#
|
|
15
|
-
# Note: This hook covers Edit, Write, and NotebookEdit tools. Bash tool writes
|
|
16
|
-
# (echo/sed/tee/cp) are not intercepted — the CLAUDE.md instruction is the
|
|
17
|
-
# enforcement layer for those. Covering Bash reliably would require parsing
|
|
18
|
-
# arbitrary shell commands, which is brittle.
|
|
19
|
-
|
|
20
|
-
# Guard: jq required for JSON parsing
|
|
21
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
22
|
-
exit 0
|
|
23
|
-
fi
|
|
24
|
-
|
|
25
|
-
# Skip in CI environments
|
|
26
|
-
if [[ "${CI:-}" == "true" ]] || [[ -n "${GITHUB_ACTIONS:-}" ]]; then
|
|
27
|
-
exit 0
|
|
28
|
-
fi
|
|
29
|
-
|
|
30
|
-
INPUT=$(cat)
|
|
31
|
-
TOOL_NAME=$(printf '%s' "$INPUT" | jq -r '.tool_name // ""') || exit 0
|
|
32
|
-
|
|
33
|
-
# Extract file path based on tool (NotebookEdit uses notebook_path, not file_path)
|
|
34
|
-
case "$TOOL_NAME" in
|
|
35
|
-
Edit|Write)
|
|
36
|
-
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // ""') || exit 0
|
|
37
|
-
;;
|
|
38
|
-
NotebookEdit)
|
|
39
|
-
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.notebook_path // .tool_input.file_path // ""') || exit 0
|
|
40
|
-
;;
|
|
41
|
-
*)
|
|
42
|
-
exit 0
|
|
43
|
-
;;
|
|
44
|
-
esac
|
|
45
|
-
|
|
46
|
-
if [[ -z "$FILE_PATH" ]]; then
|
|
47
|
-
exit 0
|
|
48
|
-
fi
|
|
49
|
-
|
|
50
|
-
# Find an existing directory to run git commands in
|
|
51
|
-
if [[ -d "$FILE_PATH" ]]; then
|
|
52
|
-
CHECK_DIR="$FILE_PATH"
|
|
53
|
-
elif [[ -e "$FILE_PATH" ]]; then
|
|
54
|
-
CHECK_DIR=$(dirname "$FILE_PATH")
|
|
55
|
-
else
|
|
56
|
-
# File doesn't exist yet — walk up to find an existing directory
|
|
57
|
-
CHECK_DIR=$(dirname "$FILE_PATH")
|
|
58
|
-
while [[ ! -d "$CHECK_DIR" ]] && [[ "$CHECK_DIR" != "/" ]]; do
|
|
59
|
-
CHECK_DIR=$(dirname "$CHECK_DIR")
|
|
60
|
-
done
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
if [[ ! -d "$CHECK_DIR" ]]; then
|
|
64
|
-
exit 0
|
|
65
|
-
fi
|
|
66
|
-
|
|
67
|
-
# Check if we're inside a git repository at all
|
|
68
|
-
GIT_DIR=$(cd "$CHECK_DIR" && git rev-parse --git-dir 2>/dev/null) || exit 0
|
|
69
|
-
|
|
70
|
-
# Make relative paths absolute
|
|
71
|
-
if [[ "$GIT_DIR" != /* ]]; then
|
|
72
|
-
GIT_DIR=$(cd "$CHECK_DIR" && cd "$GIT_DIR" && pwd) || exit 0
|
|
73
|
-
fi
|
|
74
|
-
|
|
75
|
-
# If git-dir contains /worktrees/, we're in a worktree — allow
|
|
76
|
-
if [[ "$GIT_DIR" == *".git/worktrees/"* ]]; then
|
|
77
|
-
exit 0
|
|
78
|
-
fi
|
|
79
|
-
|
|
80
|
-
# We're in the main checkout — block
|
|
81
|
-
echo "❌ Blocked: Cannot modify files in the main checkout." >&2
|
|
82
|
-
echo " Create a worktree first: /worktree or /start <issue>" >&2
|
|
83
|
-
echo " File: $FILE_PATH" >&2
|
|
84
|
-
exit 2
|
data/.claude/settings.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Skill(worktree)",
|
|
5
|
-
"Skill(start)",
|
|
6
|
-
"Bash(git worktree:*)",
|
|
7
|
-
"Bash(git stash:*)"
|
|
8
|
-
]
|
|
9
|
-
},
|
|
10
|
-
"hooks": {
|
|
11
|
-
"PreToolUse": [
|
|
12
|
-
{
|
|
13
|
-
"matcher": "Edit|Write|NotebookEdit",
|
|
14
|
-
"hooks": [
|
|
15
|
-
{
|
|
16
|
-
"type": "command",
|
|
17
|
-
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-worktree.sh",
|
|
18
|
-
"timeout": 10
|
|
19
|
-
}
|
|
20
|
-
]
|
|
21
|
-
}
|
|
22
|
-
]
|
|
23
|
-
}
|
|
24
|
-
}
|