docketeer-1password 0.0.10__tar.gz

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.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: docketeer-1password
3
+ Version: 0.0.10
4
+ Summary: 1Password vault plugin for Docketeer
5
+ Project-URL: Homepage, https://github.com/chrisguidry/docketeer
6
+ Project-URL: Repository, https://github.com/chrisguidry/docketeer
7
+ Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
8
+ Author-email: Chris Guidry <guid@omg.lol>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Classifier: Topic :: Security
15
+ Requires-Python: >=3.12
16
+ Requires-Dist: docketeer
17
+ Description-Content-Type: text/markdown
18
+
19
+ # docketeer-1password
20
+
21
+ 1Password vault plugin for [Docketeer](https://github.com/chrisguidry/docketeer).
22
+
23
+ Provides secrets management backed by 1Password via the `op` CLI and service
24
+ account tokens. Secret names use `vault/item/field` paths (e.g.
25
+ `Agent/db-cred/password`).
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install docketeer-1password
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ Set `DOCKETEER_OP_SERVICE_ACCOUNT_TOKEN` in the environment to authenticate
36
+ with 1Password. The plugin translates this to `OP_SERVICE_ACCOUNT_TOKEN` when
37
+ running `op` commands. The service account grants access to one or more
38
+ 1Password vaults.
@@ -0,0 +1,20 @@
1
+ # docketeer-1password
2
+
3
+ 1Password vault plugin for [Docketeer](https://github.com/chrisguidry/docketeer).
4
+
5
+ Provides secrets management backed by 1Password via the `op` CLI and service
6
+ account tokens. Secret names use `vault/item/field` paths (e.g.
7
+ `Agent/db-cred/password`).
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install docketeer-1password
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Set `DOCKETEER_OP_SERVICE_ACCOUNT_TOKEN` in the environment to authenticate
18
+ with 1Password. The plugin translates this to `OP_SERVICE_ACCOUNT_TOKEN` when
19
+ running `op` commands. The service account grants access to one or more
20
+ 1Password vaults.
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "docketeer-1password"
3
+ dynamic = ["version"]
4
+ description = "1Password vault plugin for Docketeer"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Chris Guidry", email = "guid@omg.lol" }
8
+ ]
9
+ license = "MIT"
10
+ requires-python = ">=3.12"
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Topic :: Security",
16
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
17
+ ]
18
+ dependencies = [
19
+ "docketeer",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/chrisguidry/docketeer"
24
+ Repository = "https://github.com/chrisguidry/docketeer"
25
+ Issues = "https://github.com/chrisguidry/docketeer/issues"
26
+
27
+ [project.entry-points."docketeer.vault"]
28
+ onepassword = "docketeer_1password"
29
+
30
+ [build-system]
31
+ requires = ["hatchling", "hatch-vcs"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.version]
35
+ source = "vcs"
36
+ raw-options.root = ".."
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/docketeer_1password"]
40
+
41
+ [tool.uv.sources]
42
+ docketeer = { workspace = true }
43
+
44
+ [tool.pytest.ini_options]
45
+ minversion = "9.0"
46
+ addopts = [
47
+ "--import-mode=importlib",
48
+ "--cov=docketeer_1password",
49
+ "--cov-config=../pyproject.toml",
50
+ "--cov-branch",
51
+ "--cov-report=term-missing",
52
+ "--cov-fail-under=100",
53
+ ]
54
+ asyncio_mode = "auto"
55
+ filterwarnings = [
56
+ "error",
57
+ ]
@@ -0,0 +1,10 @@
1
+ from docketeer import environment
2
+ from docketeer_1password.vault import OnePasswordVault
3
+
4
+
5
+ def create_vault() -> OnePasswordVault:
6
+ token = environment.get_str("OP_SERVICE_ACCOUNT_TOKEN")
7
+ return OnePasswordVault(token=token)
8
+
9
+
10
+ __all__ = ["OnePasswordVault", "create_vault"]
@@ -0,0 +1,121 @@
1
+ """1Password vault implementation using the op CLI."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+
7
+ from docketeer.vault import SecretReference, Vault
8
+
9
+
10
+ def _parse_name(name: str) -> tuple[str, str, str]:
11
+ """Split a 'vault/item/field' path into (vault, item, field)."""
12
+ parts = name.split("/", 2)
13
+ if len(parts) != 3:
14
+ raise ValueError(f"Secret name must be 'vault/item/field', got: {name!r}")
15
+ return parts[0], parts[1], parts[2]
16
+
17
+
18
+ class OnePasswordVault(Vault):
19
+ """Vault backed by 1Password via the op CLI.
20
+
21
+ Auth uses DOCKETEER_OP_SERVICE_ACCOUNT_TOKEN, which gets translated to
22
+ OP_SERVICE_ACCOUNT_TOKEN in the subprocess environment so the op CLI
23
+ picks it up.
24
+ """
25
+
26
+ def __init__(self, token: str) -> None:
27
+ self._token = token
28
+
29
+ async def _run_op(self, *args: str) -> str:
30
+ """Run an op CLI command and return stdout."""
31
+ env = {**os.environ, "OP_SERVICE_ACCOUNT_TOKEN": self._token}
32
+ proc = await asyncio.create_subprocess_exec(
33
+ "op",
34
+ *args,
35
+ env=env,
36
+ stdout=asyncio.subprocess.PIPE,
37
+ stderr=asyncio.subprocess.PIPE,
38
+ )
39
+ stdout, stderr = await proc.communicate()
40
+ if proc.returncode != 0:
41
+ raise RuntimeError(
42
+ f"op {' '.join(args[:2])} failed: {stderr.decode(errors='replace').strip()}"
43
+ )
44
+ return stdout.decode(errors="replace").strip()
45
+
46
+ async def list(self) -> list[SecretReference]:
47
+ vaults_raw = await self._run_op("vault", "list", "--format", "json")
48
+ vaults = json.loads(vaults_raw)
49
+ if not vaults:
50
+ return []
51
+
52
+ refs: list[SecretReference] = []
53
+ for v in vaults:
54
+ vault_name = v["name"]
55
+ items_raw = await self._run_op(
56
+ "item", "list", "--vault", v["id"], "--format", "json"
57
+ )
58
+ items = json.loads(items_raw)
59
+ for item in items:
60
+ detail_raw = await self._run_op(
61
+ "item", "get", item["id"], "--vault", v["id"], "--format", "json"
62
+ )
63
+ detail = json.loads(detail_raw)
64
+ for field in detail.get("fields", []):
65
+ refs.append(
66
+ SecretReference(
67
+ name=f"{vault_name}/{item['title']}/{field['label']}"
68
+ )
69
+ )
70
+ return refs
71
+
72
+ async def resolve(self, name: str) -> str:
73
+ vault, item, field = _parse_name(name)
74
+ return await self._run_op(
75
+ "item",
76
+ "get",
77
+ item,
78
+ "--vault",
79
+ vault,
80
+ "--fields",
81
+ field,
82
+ "--reveal",
83
+ )
84
+
85
+ async def store(self, name: str, value: str) -> None:
86
+ vault, item, field = _parse_name(name)
87
+ await self._run_op(
88
+ "item",
89
+ "create",
90
+ "--category",
91
+ "Password",
92
+ "--vault",
93
+ vault,
94
+ "--title",
95
+ item,
96
+ f"{field}={value}",
97
+ )
98
+
99
+ async def generate(self, name: str, length: int = 32) -> None:
100
+ vault, item, field = _parse_name(name)
101
+ await self._run_op(
102
+ "item",
103
+ "create",
104
+ "--category",
105
+ "Password",
106
+ "--vault",
107
+ vault,
108
+ "--title",
109
+ item,
110
+ f"--generate-password={length},letters,digits,symbols",
111
+ )
112
+
113
+ async def delete(self, name: str) -> None:
114
+ vault, item, field = _parse_name(name)
115
+ await self._run_op(
116
+ "item",
117
+ "delete",
118
+ item,
119
+ "--vault",
120
+ vault,
121
+ )
File without changes
@@ -0,0 +1,14 @@
1
+ """Tests for the package entry point."""
2
+
3
+ import os
4
+ from unittest.mock import patch
5
+
6
+ from docketeer_1password import create_vault
7
+ from docketeer_1password.vault import OnePasswordVault
8
+
9
+
10
+ def test_create_vault_reads_docketeer_env_var():
11
+ with patch.dict(os.environ, {"DOCKETEER_OP_SERVICE_ACCOUNT_TOKEN": "sa-tok-123"}):
12
+ vault = create_vault()
13
+ assert isinstance(vault, OnePasswordVault)
14
+ assert vault._token == "sa-tok-123"
@@ -0,0 +1,305 @@
1
+ """Tests for the 1Password vault implementation."""
2
+
3
+ import json
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from docketeer_1password.vault import OnePasswordVault
9
+
10
+
11
+ @pytest.fixture()
12
+ def vault() -> OnePasswordVault:
13
+ return OnePasswordVault(token="test-sa-token")
14
+
15
+
16
+ def _mock_op(stdout: str = "", returncode: int = 0) -> AsyncMock:
17
+ """Create a mock for asyncio.create_subprocess_exec."""
18
+ proc = AsyncMock()
19
+ proc.communicate.return_value = (stdout.encode(), b"")
20
+ proc.returncode = returncode
21
+ return proc
22
+
23
+
24
+ # --- env ---
25
+
26
+
27
+ async def test_op_receives_service_account_token(vault: OnePasswordVault):
28
+ vaults_json = json.dumps([])
29
+
30
+ async def fake_exec(*args: object, **kwargs: object) -> AsyncMock:
31
+ env: dict[str, str] = kwargs.get("env", {}) # type: ignore[assignment]
32
+ assert env["OP_SERVICE_ACCOUNT_TOKEN"] == "test-sa-token"
33
+ assert "PATH" in env
34
+ return _mock_op(vaults_json)
35
+
36
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
37
+ await vault.list()
38
+
39
+
40
+ # --- list ---
41
+
42
+
43
+ def _item_detail(fields: list[dict[str, str]]) -> str:
44
+ """Build a JSON item detail response with the given fields."""
45
+ return json.dumps({"fields": fields})
46
+
47
+
48
+ async def test_list_secrets(vault: OnePasswordVault):
49
+ vaults_json = json.dumps([{"id": "abc", "name": "Agent"}])
50
+ items_json = json.dumps(
51
+ [
52
+ {"id": "item1", "title": "api-key"},
53
+ {"id": "item2", "title": "db-cred"},
54
+ ]
55
+ )
56
+ detail1 = _item_detail([{"label": "password", "id": "pw"}])
57
+ detail2 = _item_detail(
58
+ [
59
+ {"label": "username", "id": "un"},
60
+ {"label": "password", "id": "pw"},
61
+ ]
62
+ )
63
+
64
+ responses = [vaults_json, items_json, detail1, detail2]
65
+ call_count = 0
66
+
67
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
68
+ nonlocal call_count
69
+ call_count += 1
70
+ return _mock_op(responses[call_count - 1])
71
+
72
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
73
+ refs = await vault.list()
74
+
75
+ names = [r.name for r in refs]
76
+ assert "Agent/api-key/password" in names
77
+ assert "Agent/db-cred/username" in names
78
+ assert "Agent/db-cred/password" in names
79
+
80
+
81
+ async def test_list_multiple_vaults(vault: OnePasswordVault):
82
+ vaults_json = json.dumps(
83
+ [
84
+ {"id": "v1", "name": "Vault1"},
85
+ {"id": "v2", "name": "Vault2"},
86
+ ]
87
+ )
88
+ items_v1 = json.dumps([{"id": "i1", "title": "secret-a"}])
89
+ items_v2 = json.dumps([{"id": "i2", "title": "secret-b"}])
90
+ detail_a = _item_detail([{"label": "password", "id": "pw"}])
91
+ detail_b = _item_detail([{"label": "token", "id": "tk"}])
92
+
93
+ responses = [vaults_json, items_v1, detail_a, items_v2, detail_b]
94
+ call_count = 0
95
+
96
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
97
+ nonlocal call_count
98
+ call_count += 1
99
+ return _mock_op(responses[call_count - 1])
100
+
101
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
102
+ refs = await vault.list()
103
+
104
+ names = {r.name for r in refs}
105
+ assert names == {"Vault1/secret-a/password", "Vault2/secret-b/token"}
106
+
107
+
108
+ async def test_list_empty(vault: OnePasswordVault):
109
+ vaults_json = json.dumps([{"id": "v1", "name": "Agent"}])
110
+ items_json = json.dumps([])
111
+
112
+ responses = [vaults_json, items_json]
113
+ call_count = 0
114
+
115
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
116
+ nonlocal call_count
117
+ call_count += 1
118
+ return _mock_op(responses[call_count - 1])
119
+
120
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
121
+ refs = await vault.list()
122
+
123
+ assert refs == []
124
+
125
+
126
+ async def test_list_no_vaults(vault: OnePasswordVault):
127
+ vaults_json = json.dumps([])
128
+
129
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
130
+ return _mock_op(vaults_json)
131
+
132
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
133
+ refs = await vault.list()
134
+
135
+ assert refs == []
136
+
137
+
138
+ # --- resolve ---
139
+
140
+
141
+ async def test_resolve(vault: OnePasswordVault):
142
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
143
+ return _mock_op("sk-abc123\n")
144
+
145
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
146
+ value = await vault.resolve("Agent/api-key/password")
147
+
148
+ assert value == "sk-abc123"
149
+ cmd = list(mock.call_args.args)
150
+ assert "--fields" in cmd
151
+ field_idx = cmd.index("--fields")
152
+ assert cmd[field_idx + 1] == "password"
153
+
154
+
155
+ async def test_resolve_custom_field(vault: OnePasswordVault):
156
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
157
+ return _mock_op("admin")
158
+
159
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
160
+ value = await vault.resolve("Agent/db-cred/username")
161
+
162
+ assert value == "admin"
163
+ cmd = list(mock.call_args.args)
164
+ field_idx = cmd.index("--fields")
165
+ assert cmd[field_idx + 1] == "username"
166
+
167
+
168
+ async def test_resolve_bad_path(vault: OnePasswordVault):
169
+ with pytest.raises(ValueError, match="vault/item/field"):
170
+ await vault.resolve("no-slashes")
171
+
172
+
173
+ async def test_resolve_two_parts(vault: OnePasswordVault):
174
+ with pytest.raises(ValueError, match="vault/item/field"):
175
+ await vault.resolve("Agent/api-key")
176
+
177
+
178
+ async def test_resolve_op_failure(vault: OnePasswordVault):
179
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
180
+ proc = _mock_op("", returncode=1)
181
+ proc.communicate.return_value = (b"", b"not found")
182
+ return proc
183
+
184
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
185
+ with pytest.raises(RuntimeError, match="not found"):
186
+ await vault.resolve("Agent/missing/password")
187
+
188
+
189
+ # --- store ---
190
+
191
+
192
+ async def test_store(vault: OnePasswordVault):
193
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
194
+ return _mock_op()
195
+
196
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
197
+ await vault.store("Agent/new-secret/password", "my-value")
198
+
199
+ cmd = list(mock.call_args.args)
200
+ assert "--vault" in cmd
201
+ assert "Agent" in cmd
202
+ assert "--title" in cmd
203
+ assert "new-secret" in cmd
204
+ assert "password=my-value" in cmd
205
+
206
+
207
+ async def test_store_custom_field(vault: OnePasswordVault):
208
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
209
+ return _mock_op()
210
+
211
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
212
+ await vault.store("Agent/db-cred/api_token", "tok-123")
213
+
214
+ cmd = list(mock.call_args.args)
215
+ assert "api_token=tok-123" in cmd
216
+
217
+
218
+ async def test_store_bad_path(vault: OnePasswordVault):
219
+ with pytest.raises(ValueError, match="vault/item/field"):
220
+ await vault.store("no-slash", "value")
221
+
222
+
223
+ async def test_store_op_failure(vault: OnePasswordVault):
224
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
225
+ proc = _mock_op("", returncode=1)
226
+ proc.communicate.return_value = (b"", b"permission denied")
227
+ return proc
228
+
229
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
230
+ with pytest.raises(RuntimeError, match="permission denied"):
231
+ await vault.store("Agent/secret/password", "value")
232
+
233
+
234
+ # --- generate ---
235
+
236
+
237
+ async def test_generate(vault: OnePasswordVault):
238
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
239
+ return _mock_op()
240
+
241
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
242
+ await vault.generate("Agent/random-key/password", length=24)
243
+
244
+ cmd = list(mock.call_args.args)
245
+ assert any("24" in str(a) for a in cmd)
246
+
247
+
248
+ async def test_generate_default_length(vault: OnePasswordVault):
249
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
250
+ return _mock_op()
251
+
252
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
253
+ await vault.generate("Agent/random-key/password")
254
+
255
+ cmd = list(mock.call_args.args)
256
+ assert any("32" in str(a) for a in cmd)
257
+
258
+
259
+ async def test_generate_bad_path(vault: OnePasswordVault):
260
+ with pytest.raises(ValueError, match="vault/item/field"):
261
+ await vault.generate("no-slash")
262
+
263
+
264
+ async def test_generate_op_failure(vault: OnePasswordVault):
265
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
266
+ proc = _mock_op("", returncode=1)
267
+ proc.communicate.return_value = (b"", b"error")
268
+ return proc
269
+
270
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
271
+ with pytest.raises(RuntimeError, match="error"):
272
+ await vault.generate("Agent/secret/password")
273
+
274
+
275
+ # --- delete ---
276
+
277
+
278
+ async def test_delete(vault: OnePasswordVault):
279
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
280
+ return _mock_op()
281
+
282
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec) as mock:
283
+ await vault.delete("Agent/old-secret/password")
284
+
285
+ cmd = list(mock.call_args.args)
286
+ assert "delete" in cmd
287
+ assert "old-secret" in cmd
288
+ assert "--vault" in cmd
289
+ assert "Agent" in cmd
290
+
291
+
292
+ async def test_delete_bad_path(vault: OnePasswordVault):
293
+ with pytest.raises(ValueError, match="vault/item/field"):
294
+ await vault.delete("no-slash")
295
+
296
+
297
+ async def test_delete_op_failure(vault: OnePasswordVault):
298
+ async def fake_exec(*args: object, **_kwargs: object) -> AsyncMock:
299
+ proc = _mock_op("", returncode=1)
300
+ proc.communicate.return_value = (b"", b"not found")
301
+ return proc
302
+
303
+ with patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
304
+ with pytest.raises(RuntimeError, match="not found"):
305
+ await vault.delete("Agent/missing/password")