pal-found-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. pal_found_cli/__init__.py +1 -0
  2. pal_found_cli/_version.py +24 -0
  3. pal_found_cli/admin/__init__.py +2 -0
  4. pal_found_cli/admin/scripts/__init__.py +2 -0
  5. pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
  6. pal_found_cli/aip_agents/__init__.py +1 -0
  7. pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
  8. pal_found_cli/aip_agents/scripts/__init__.py +1 -0
  9. pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
  10. pal_found_cli/audit/__init__.py +1 -0
  11. pal_found_cli/audit/metadata-allow-list.md +4 -0
  12. pal_found_cli/audit/scripts/__init__.py +1 -0
  13. pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
  14. pal_found_cli/checkpoints/__init__.py +9 -0
  15. pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
  16. pal_found_cli/checkpoints/scripts/__init__.py +1 -0
  17. pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
  18. pal_found_cli/common/__init__.py +56 -0
  19. pal_found_cli/common/access_control_guard.py +591 -0
  20. pal_found_cli/common/async_client_factory.py +161 -0
  21. pal_found_cli/common/auth_provider.py +82 -0
  22. pal_found_cli/common/binary_download_handler.py +288 -0
  23. pal_found_cli/common/config_loader.py +388 -0
  24. pal_found_cli/common/error_serializer.py +412 -0
  25. pal_found_cli/common/log_setup.py +241 -0
  26. pal_found_cli/common/metadata-allow-list.md +169 -0
  27. pal_found_cli/common/output_formatter.py +267 -0
  28. pal_found_cli/common/pagination_helper.py +212 -0
  29. pal_found_cli/common/retry.py +617 -0
  30. pal_found_cli/common/sdk_error_utils.py +112 -0
  31. pal_found_cli/common/session_manager.py +461 -0
  32. pal_found_cli/common/tracing_provider.py +107 -0
  33. pal_found_cli/connectivity/__init__.py +9 -0
  34. pal_found_cli/connectivity/metadata-allow-list.md +24 -0
  35. pal_found_cli/connectivity/scripts/__init__.py +1 -0
  36. pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
  37. pal_found_cli/data_health/__init__.py +9 -0
  38. pal_found_cli/data_health/metadata-allow-list.md +10 -0
  39. pal_found_cli/data_health/scripts/__init__.py +1 -0
  40. pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
  41. pal_found_cli/datasets/__init__.py +1 -0
  42. pal_found_cli/datasets/scripts/__init__.py +1 -0
  43. pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
  44. pal_found_cli/filesystem/__init__.py +2 -0
  45. pal_found_cli/filesystem/scripts/__init__.py +2 -0
  46. pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
  47. pal_found_cli/functions/__init__.py +2 -0
  48. pal_found_cli/functions/scripts/__init__.py +2 -0
  49. pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
  50. pal_found_cli/language_models/__init__.py +1 -0
  51. pal_found_cli/language_models/metadata-allow-list.md +6 -0
  52. pal_found_cli/language_models/scripts/__init__.py +1 -0
  53. pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
  54. pal_found_cli/media_sets/__init__.py +9 -0
  55. pal_found_cli/media_sets/metadata-allow-list.md +23 -0
  56. pal_found_cli/media_sets/scripts/__init__.py +1 -0
  57. pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
  58. pal_found_cli/models/__init__.py +5 -0
  59. pal_found_cli/models/metadata-allow-list.md +27 -0
  60. pal_found_cli/models/scripts/__init__.py +1 -0
  61. pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
  62. pal_found_cli/ontologies/__init__.py +1 -0
  63. pal_found_cli/ontologies/scripts/__init__.py +1 -0
  64. pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
  65. pal_found_cli/orchestration/__init__.py +9 -0
  66. pal_found_cli/orchestration/metadata-allow-list.md +24 -0
  67. pal_found_cli/orchestration/scripts/__init__.py +1 -0
  68. pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
  69. pal_found_cli/sql_queries/__init__.py +9 -0
  70. pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
  71. pal_found_cli/sql_queries/scripts/__init__.py +1 -0
  72. pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
  73. pal_found_cli/streams/__init__.py +9 -0
  74. pal_found_cli/streams/metadata-allow-list.md +19 -0
  75. pal_found_cli/streams/scripts/__init__.py +1 -0
  76. pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
  77. pal_found_cli/third_party_applications/__init__.py +9 -0
  78. pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
  79. pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
  80. pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
  81. pal_found_cli/widgets/__init__.py +9 -0
  82. pal_found_cli/widgets/metadata-allow-list.md +12 -0
  83. pal_found_cli/widgets/scripts/__init__.py +1 -0
  84. pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
  85. pal_found_cli-0.1.2.dist-info/METADATA +74 -0
  86. pal_found_cli-0.1.2.dist-info/RECORD +89 -0
  87. pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
  88. pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
  89. pal_found_cli-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,591 @@
1
+ #!/usr/bin/env python3
2
+ """AccessControlGuard — 8-step precedence model (SRS §4.2, ADR-007).
3
+
4
+ Implements the access control evaluation order:
5
+ 0. Global ENABLED absolute gate
6
+ 1. Operation-level ENABLED
7
+ 2. Namespace-level ENABLED
8
+ 3. Operation-level READONLY override (false = permit write when parent READONLY=true)
9
+ 4. Namespace-level READONLY override (false = permit write when parent READONLY=true)
10
+ 5. Global READONLY (blocks writes)
11
+ 6. Namespace-level METADATA_ONLY override (false = permit content when parent METADATA_ONLY=true)
12
+ 7. Global METADATA_ONLY (blocks content reads not in allow-list)
13
+ 8. Permit
14
+
15
+ Per ADR-007, operation-level READONLY=true is NOT independently supported.
16
+ Only READONLY=false override of parent READONLY=true is valid.
17
+ """
18
+
19
+ import logging
20
+ import os
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from pal_found_cli.common.config_loader import ConfigLoader
26
+ from pal_found_cli.common.error_serializer import EXIT_ACCESS_CONTROL
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # Write operations — heuristic: operations whose name contains known write verbs
32
+ # are classified as writes; all others are reads.
33
+ _WRITE_VERBS = frozenset(
34
+ {
35
+ "create",
36
+ "delete",
37
+ "put",
38
+ "post",
39
+ "patch",
40
+ "replace",
41
+ "update",
42
+ "add",
43
+ "remove",
44
+ "commit",
45
+ "abort",
46
+ "upload",
47
+ "download",
48
+ "modify",
49
+ "upsert",
50
+ "cancel",
51
+ "revoke",
52
+ "preregister",
53
+ "execute",
54
+ "apply",
55
+ "publish",
56
+ "deploy",
57
+ "run",
58
+ "transform",
59
+ "clear",
60
+ "purge",
61
+ "build",
62
+ "blocking_continue",
63
+ "streaming_continue",
64
+ "rag_context",
65
+ "messages",
66
+ "embeddings",
67
+ # Mutating/cost-bearing operations introduced by the models and
68
+ # orchestration namespaces (DESIGN-013/014): these verbs mutate
69
+ # resources or launch billable work and must never be classified as
70
+ # reads under READONLY/METADATA_ONLY tiers.
71
+ "launch",
72
+ "promote",
73
+ "pause",
74
+ "unpause",
75
+ # Streams namespace (DESIGN-016): stream.reset and
76
+ # subscriber.reset_offsets destroy or rewind stream state and must
77
+ # stay write-classified.
78
+ "reset",
79
+ # Media Sets namespace (DESIGN-018): media_set.register registers a
80
+ # media item into a media set and media_set.calculate starts a
81
+ # transformation job. Both mutate media-set state or launch billable
82
+ # work and must never be classified as reads under the
83
+ # READONLY/METADATA_ONLY tiers.
84
+ "register",
85
+ "calculate",
86
+ # Third-Party Applications namespace (DESIGN-021): website.deploy and
87
+ # website.undeploy mutate deployed Website state and must never be
88
+ # classified as reads under the READONLY/METADATA_ONLY tiers.
89
+ "deploy",
90
+ "undeploy",
91
+ # Widgets namespace (DESIGN-022, corrected 8-op catalog): the final
92
+ # write set is dev_mode_settings.enable, dev_mode_settings.
93
+ # set_widget_set_by_id, release.delete, and repository.publish.
94
+ # ``enable`` is token-scoped but still a mutating POST; ``set_widget_set``
95
+ # covers both set_widget_set and set_widget_set_by_id via the prefix
96
+ # match. ``delete`` and ``publish`` were already classified as writes.
97
+ "enable",
98
+ "set_widget_set",
99
+ }
100
+ )
101
+
102
+ # Read-only metadata operations that are permitted even in METADATA_ONLY tier
103
+ # when NOT in the allow-list file (these are the minimal structural metadata)
104
+ _METADATA_VERBS = frozenset(
105
+ {
106
+ "get",
107
+ "list",
108
+ "search",
109
+ }
110
+ )
111
+
112
+
113
+ class AccessControlError(Exception):
114
+ """Access control policy violation (exit code 8).
115
+
116
+ Attributes
117
+ ----------
118
+ message : str
119
+ Human-readable error message.
120
+ exit_code : int
121
+ Exit code (always EXIT_ACCESS_CONTROL = 8).
122
+ step : int
123
+ Which step of the 8-step precedence model triggered the block.
124
+ """
125
+
126
+ def __init__(
127
+ self,
128
+ message: str,
129
+ step: int = 0,
130
+ blocked_rule: dict[str, Any] | None = None,
131
+ ) -> None:
132
+ super().__init__(message)
133
+ self.message = message
134
+ self.exit_code = EXIT_ACCESS_CONTROL
135
+ self.step = step
136
+ self.blocked_rule = blocked_rule or {"step": step, "message": message}
137
+ self.details = {"blocked_rule": self.blocked_rule}
138
+
139
+
140
+ class AccessControlGuard:
141
+ """Evaluates access control using 8-step precedence model (SRS §4.2).
142
+
143
+ Parameters
144
+ ----------
145
+ cfg : ConfigLoader
146
+ Configuration instance.
147
+ namespace : str
148
+ Namespace name (e.g., "DATASETS").
149
+ metadata_allowlist_path : str, optional
150
+ Path to metadata allow-list file; if None, uses the canonical location.
151
+
152
+ Usage
153
+ -----
154
+ >>> guard = AccessControlGuard(cfg, "DATASETS")
155
+ >>> guard.check("dataset", "create") # raises AccessControlError on block
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ cfg: ConfigLoader,
161
+ namespace: str,
162
+ metadata_allowlist_path: str | None = None,
163
+ ) -> None:
164
+ self.cfg = cfg
165
+ self.namespace = namespace.upper()
166
+ self._metadata_allowlist: set[str] | None = None
167
+ self._metadata_allowlist_path = metadata_allowlist_path
168
+
169
+ def _is_write_operation(self, operation: str) -> bool:
170
+ """Classify an operation as a write operation.
171
+
172
+ Heuristic: if the operation name (lowercase) starts with any known
173
+ write verb, it's classified as a write. This covers the 355 SDK
174
+ operations without hardcoding a per-namespace list.
175
+
176
+ Parameters
177
+ ----------
178
+ operation : str
179
+ Operation name (e.g., "create", "get", "put_schema").
180
+
181
+ Returns
182
+ -------
183
+ bool
184
+ True if the operation is classified as a write.
185
+ """
186
+ op_lower = operation.lower()
187
+ for verb in _WRITE_VERBS:
188
+ if op_lower == verb or op_lower.startswith(verb + "_"):
189
+ return True
190
+ return False
191
+
192
+ def _operation_env_key(self, resource: str, operation: str) -> str:
193
+ """Build canonical env key suffix for an operation.
194
+
195
+ Follows the canonical transformation rule (SRS §5.2): the entire
196
+ SDK path ``{ns}.{resource}.{operation}`` is uppercased and joined
197
+ with underscores, keeping the operation name verbatim (NOT
198
+ reordered by verb). This ensures ``put_schema`` maps to
199
+ ``DATASETS_DATASET_PUT_SCHEMA``, matching the canonical env-var
200
+ reference table and the Step-3 ``_READONLY=false`` override.
201
+
202
+ Parameters
203
+ ----------
204
+ resource : str
205
+ Resource/class name (e.g., "dataset").
206
+ operation : str
207
+ Operation name (e.g., "put_schema", "get").
208
+
209
+ Returns
210
+ -------
211
+ str
212
+ Env key suffix such as ``DATASETS_DATASET_PUT_SCHEMA``.
213
+ """
214
+ return f"{self.namespace}_{resource.upper()}_{operation.upper()}"
215
+
216
+ @staticmethod
217
+ def _is_true_env(value: str | None) -> bool:
218
+ return value is not None and value.lower() in ("true", "1", "yes", "on")
219
+
220
+ @staticmethod
221
+ def _is_false_env(value: str | None) -> bool:
222
+ return value is not None and value.lower() in ("false", "0", "no", "off")
223
+
224
+ def _get_global_readonly(self) -> bool:
225
+ env_val = os.environ.get("FOUNDRY_AGENTIC_CLI_READONLY")
226
+ if env_val is not None:
227
+ return self._is_true_env(env_val)
228
+ return bool(self.cfg.global_readonly)
229
+
230
+ def _get_global_enabled(self) -> bool:
231
+ """Return false only when global CLI enablement is explicitly disabled."""
232
+ env_val = os.environ.get("FOUNDRY_AGENTIC_CLI_ENABLED")
233
+ if env_val is not None:
234
+ return not self._is_false_env(env_val)
235
+ configured = getattr(self.cfg, "global_enabled", True)
236
+ return bool(configured)
237
+
238
+ def _get_global_metadata_only(self) -> bool:
239
+ env_val = os.environ.get("FOUNDRY_AGENTIC_CLI_METADATA_ONLY")
240
+ if env_val is not None:
241
+ return self._is_true_env(env_val)
242
+ return bool(self.cfg.global_metadata_only)
243
+
244
+ def _get_namespace_readonly(self) -> bool:
245
+ """Check if namespace-level READONLY is explicitly set to true.
246
+
247
+ Returns
248
+ -------
249
+ bool
250
+ True if FOUNDRY_AGENTIC_CLI_{NS}_READONLY is explicitly "true".
251
+ """
252
+ ns_readonly_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_READONLY"
253
+ ns_readonly_val = os.environ.get(ns_readonly_env)
254
+ if ns_readonly_val is None:
255
+ return False
256
+ return ns_readonly_val.lower() in ("true", "1", "yes")
257
+
258
+ def _get_namespace_metadata_only(self) -> bool:
259
+ """Check if namespace-level METADATA_ONLY is explicitly set to true.
260
+
261
+ Returns
262
+ -------
263
+ bool
264
+ True if FOUNDRY_AGENTIC_CLI_{NS}_METADATA_ONLY is explicitly "true".
265
+ """
266
+ ns_metadata_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_METADATA_ONLY"
267
+ ns_metadata_val = os.environ.get(ns_metadata_env)
268
+ if ns_metadata_val is None:
269
+ return False
270
+ return ns_metadata_val.lower() in ("true", "1", "yes")
271
+
272
+ def _log_decision(
273
+ self,
274
+ decision: str,
275
+ resource: str,
276
+ operation: str,
277
+ step: int,
278
+ reason: str,
279
+ ) -> None:
280
+ """Log access control decision as NDJSON (ADR-005).
281
+
282
+ Parameters
283
+ ----------
284
+ decision : str
285
+ "BLOCKED" or "PERMITTED".
286
+ resource : str
287
+ Resource name.
288
+ operation : str
289
+ Operation name.
290
+ step : int
291
+ Step number (1-8) in precedence model.
292
+ reason : str
293
+ Human-readable reason for the decision.
294
+ """
295
+ logger.info(
296
+ "Access control decision: %s (Step %d) — %s",
297
+ decision,
298
+ step,
299
+ reason,
300
+ extra={
301
+ "access_decision": decision,
302
+ "op": f"{self.namespace.lower()}.{resource.lower()}.{operation.lower()}",
303
+ "step": step,
304
+ "reason": reason,
305
+ },
306
+ )
307
+
308
+ def _load_metadata_allowlist(self) -> set[str]:
309
+ """Load metadata allow-list from canonical file.
310
+
311
+ Returns
312
+ -------
313
+ set of str
314
+ Set of operation keys (e.g., "datasets.dataset.get") permitted
315
+ in METADATA_ONLY tier.
316
+
317
+ Notes
318
+ -----
319
+ The allow-list file is a Markdown table. This parser is intentionally
320
+ lightweight and extracts lines containing "PERMITTED" to build the set.
321
+ """
322
+ allowlist_path: Path | None
323
+ if self._metadata_allowlist_path:
324
+ allowlist_path = Path(self._metadata_allowlist_path)
325
+ else:
326
+ # Core namespaces share a packaged policy so installed commands do
327
+ # not depend on the design repository or the current directory.
328
+ allowlist_path = Path(__file__).with_name("metadata-allow-list.md")
329
+
330
+ if not allowlist_path.exists():
331
+ return set()
332
+
333
+ permitted: set[str] = set()
334
+ canonical_row = re.compile(
335
+ r"^\|\s*`(?P<sdk_path>[a-z0-9_]+\.[a-z0-9_]+\.[a-z0-9_]+)`\s*"
336
+ r"\|\s*PERMITTED\s*\|",
337
+ )
338
+ text = allowlist_path.read_text(encoding="utf-8")
339
+ for line in text.splitlines():
340
+ match = canonical_row.match(line.strip())
341
+ if match:
342
+ permitted.add(match.group("sdk_path").lower())
343
+ return permitted
344
+
345
+ def _is_in_metadata_allowlist(self, resource: str, operation: str) -> bool:
346
+ """Check if an operation is in the metadata allow-list.
347
+
348
+ Parameters
349
+ ----------
350
+ resource : str
351
+ Resource name.
352
+ operation : str
353
+ Operation name.
354
+
355
+ Returns
356
+ -------
357
+ bool
358
+ True if the operation is permitted in METADATA_ONLY tier.
359
+ """
360
+ if self._metadata_allowlist is None:
361
+ self._metadata_allowlist = self._load_metadata_allowlist()
362
+
363
+ ns_lower = self.namespace.lower()
364
+ key = f"{ns_lower}.{resource.lower()}.{operation.lower()}"
365
+ return key in self._metadata_allowlist
366
+
367
+ def check(
368
+ self,
369
+ resource: str,
370
+ operation: str,
371
+ ) -> None:
372
+ """Evaluate access control for an operation (8-step precedence).
373
+
374
+ Parameters
375
+ ----------
376
+ resource : str
377
+ Resource/class name (e.g., "dataset", "branch").
378
+ operation : str
379
+ Operation name (e.g., "create", "get").
380
+
381
+ Raises
382
+ ------
383
+ AccessControlError
384
+ If access control blocks the operation.
385
+ """
386
+ full_key = self._operation_env_key(resource, operation)
387
+ is_write = self._is_write_operation(operation)
388
+ op_path = f"{self.namespace.lower()}.{resource.lower()}.{operation.lower()}"
389
+
390
+ def _blocked(
391
+ message: str, step: int, env_var: str, value: str
392
+ ) -> AccessControlError:
393
+ return AccessControlError(
394
+ message,
395
+ step=step,
396
+ blocked_rule={
397
+ "step": step,
398
+ "operation": op_path,
399
+ "env_var": env_var,
400
+ "value": value,
401
+ "message": message,
402
+ },
403
+ )
404
+
405
+ # Global disable is an absolute gate. Narrower true values cannot
406
+ # re-enable a globally disabled CLI.
407
+ if not self._get_global_enabled():
408
+ global_enabled_env = "FOUNDRY_AGENTIC_CLI_ENABLED"
409
+ global_enabled_val = os.environ.get(global_enabled_env, "false")
410
+ self._log_decision(
411
+ "BLOCKED", resource, operation, 0, "Global ENABLED=false"
412
+ )
413
+ raise _blocked(
414
+ "Operation blocked: global ENABLED=false",
415
+ 0,
416
+ global_enabled_env,
417
+ global_enabled_val,
418
+ )
419
+
420
+ # Step 1: Operation-level ENABLED
421
+ enabled_env = f"FOUNDRY_AGENTIC_CLI_{full_key}_ENABLED"
422
+ enabled_val = os.environ.get(enabled_env)
423
+ if enabled_val is not None and enabled_val.lower() == "false":
424
+ self._log_decision(
425
+ "BLOCKED", resource, operation, 1, f"ENABLED=false for {full_key}"
426
+ )
427
+ raise _blocked(
428
+ f"Operation blocked: ENABLED=false for {full_key}",
429
+ 1,
430
+ enabled_env,
431
+ enabled_val,
432
+ )
433
+
434
+ # Step 2: Namespace-level ENABLED
435
+ ns_enabled_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_ENABLED"
436
+ ns_enabled_val = os.environ.get(ns_enabled_env)
437
+ if ns_enabled_val is not None and ns_enabled_val.lower() == "false":
438
+ self._log_decision(
439
+ "BLOCKED", resource, operation, 2, f"ENABLED=false for {self.namespace}"
440
+ )
441
+ raise _blocked(
442
+ f"Namespace blocked: ENABLED=false for {self.namespace}",
443
+ 2,
444
+ ns_enabled_env,
445
+ ns_enabled_val,
446
+ )
447
+
448
+ ns_metadata_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_METADATA_ONLY"
449
+ ns_metadata_val = os.environ.get(ns_metadata_env)
450
+ namespace_metadata_only = self._is_true_env(ns_metadata_val)
451
+ namespace_metadata_override = self._is_false_env(ns_metadata_val)
452
+ global_metadata_only = self._get_global_metadata_only()
453
+ metadata_only_active = namespace_metadata_only or (
454
+ global_metadata_only and not namespace_metadata_override
455
+ )
456
+
457
+ # Steps 3-5: READONLY evaluation (only for write operations)
458
+ if is_write:
459
+ parent_readonly = (
460
+ self._get_global_readonly() or self._get_namespace_readonly()
461
+ )
462
+
463
+ if parent_readonly:
464
+ # Step 3: Operation-level READONLY override
465
+ op_readonly_env = f"FOUNDRY_AGENTIC_CLI_{full_key}_READONLY"
466
+ op_readonly_val = os.environ.get(op_readonly_env)
467
+ if op_readonly_val is not None and op_readonly_val.lower() == "false":
468
+ self._log_decision(
469
+ "PERMITTED",
470
+ resource,
471
+ operation,
472
+ 3,
473
+ f"READONLY=false override for {full_key}",
474
+ )
475
+ return # Explicit write permit for this operation
476
+
477
+ # Step 4: Namespace-level READONLY override
478
+ ns_readonly_env = f"FOUNDRY_AGENTIC_CLI_{self.namespace}_READONLY"
479
+ ns_readonly_val = os.environ.get(ns_readonly_env)
480
+ if ns_readonly_val is not None and ns_readonly_val.lower() == "false":
481
+ self._log_decision(
482
+ "PERMITTED",
483
+ resource,
484
+ operation,
485
+ 4,
486
+ f"READONLY=false override for {self.namespace}",
487
+ )
488
+ return # Explicit write permit for this namespace
489
+
490
+ if self._get_namespace_readonly():
491
+ self._log_decision(
492
+ "BLOCKED",
493
+ resource,
494
+ operation,
495
+ 4,
496
+ f"READONLY=true for {self.namespace}",
497
+ )
498
+ raise _blocked(
499
+ "Operation blocked: namespace read-only mode active",
500
+ 4,
501
+ ns_readonly_env,
502
+ "true",
503
+ )
504
+
505
+ # Step 5: Global READONLY
506
+ if self._get_global_readonly():
507
+ self._log_decision(
508
+ "BLOCKED",
509
+ resource,
510
+ operation,
511
+ 5,
512
+ "Global READONLY=true blocks write",
513
+ )
514
+ raise _blocked(
515
+ "Operation blocked: read-only mode active",
516
+ 5,
517
+ "FOUNDRY_AGENTIC_CLI_READONLY",
518
+ "true",
519
+ )
520
+
521
+ # Steps 6-7: METADATA_ONLY evaluation.
522
+ if global_metadata_only and namespace_metadata_override:
523
+ self._log_decision(
524
+ "PERMITTED",
525
+ resource,
526
+ operation,
527
+ 6,
528
+ f"METADATA_ONLY=false override for {self.namespace}",
529
+ )
530
+ return
531
+
532
+ if metadata_only_active:
533
+ metadata_env = (
534
+ ns_metadata_env
535
+ if namespace_metadata_only
536
+ else "FOUNDRY_AGENTIC_CLI_METADATA_ONLY"
537
+ )
538
+ if is_write:
539
+ step = 6 if namespace_metadata_only else 7
540
+ self._log_decision(
541
+ "BLOCKED", resource, operation, 7, "METADATA_ONLY=true blocks write"
542
+ )
543
+ raise _blocked(
544
+ "Operation blocked: metadata-only mode active",
545
+ step,
546
+ metadata_env,
547
+ "true",
548
+ )
549
+ if not self._is_in_metadata_allowlist(resource, operation):
550
+ self._log_decision(
551
+ "BLOCKED",
552
+ resource,
553
+ operation,
554
+ 7,
555
+ "Operation not in metadata allow-list",
556
+ )
557
+ raise _blocked(
558
+ "Operation blocked: not in metadata allow-list",
559
+ 7,
560
+ metadata_env,
561
+ "true",
562
+ )
563
+
564
+ # Step 8: Permit
565
+ self._log_decision("PERMITTED", resource, operation, 8, "Default full access")
566
+ return
567
+
568
+ def _is_metadata_operation(self, resource: str, operation: str) -> bool:
569
+ """Check if an operation is a structural metadata operation.
570
+
571
+ Heuristic: operations whose name starts with known metadata verbs
572
+ (get, list, search) are structural metadata and are always permitted
573
+ in METADATA_ONLY tier even if not explicitly in the allow-list.
574
+
575
+ Parameters
576
+ ----------
577
+ resource : str
578
+ Resource name.
579
+ operation : str
580
+ Operation name.
581
+
582
+ Returns
583
+ -------
584
+ bool
585
+ True if the operation is structural metadata.
586
+ """
587
+ op_lower = operation.lower()
588
+ for verb in _METADATA_VERBS:
589
+ if op_lower == verb or op_lower.startswith(verb + "_"):
590
+ return True
591
+ return False