@openlucaskaka/kagent 0.1.7
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/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fcntl
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TextFileState:
|
|
15
|
+
content: str | None
|
|
16
|
+
mode: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def workspace_transaction(workspace_root: Path) -> Iterator[None]:
|
|
21
|
+
flags = os.O_RDONLY
|
|
22
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
23
|
+
flags |= os.O_DIRECTORY
|
|
24
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
25
|
+
flags |= os.O_NOFOLLOW
|
|
26
|
+
descriptor = os.open(workspace_root, flags)
|
|
27
|
+
try:
|
|
28
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
29
|
+
yield
|
|
30
|
+
finally:
|
|
31
|
+
try:
|
|
32
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
33
|
+
finally:
|
|
34
|
+
os.close(descriptor)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def commit_text_changes(
|
|
38
|
+
workspace_root: Path,
|
|
39
|
+
staged_contents: dict[Path, str | None],
|
|
40
|
+
*,
|
|
41
|
+
original_states: dict[Path, TextFileState] | None = None,
|
|
42
|
+
target_modes: dict[Path, int] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
snapshots = original_states or capture_text_states(staged_contents)
|
|
45
|
+
resolved_target_modes = target_modes or {}
|
|
46
|
+
created_directories: list[Path] = []
|
|
47
|
+
try:
|
|
48
|
+
for target, content in staged_contents.items():
|
|
49
|
+
_reject_symlink_parts(workspace_root, target)
|
|
50
|
+
if content is None:
|
|
51
|
+
if target.exists():
|
|
52
|
+
target.unlink()
|
|
53
|
+
_fsync_directory(target.parent)
|
|
54
|
+
continue
|
|
55
|
+
_ensure_parent(
|
|
56
|
+
workspace_root,
|
|
57
|
+
target.parent,
|
|
58
|
+
created_directories=created_directories,
|
|
59
|
+
)
|
|
60
|
+
_atomic_write_text(target, content, mode=resolved_target_modes.get(target))
|
|
61
|
+
except Exception as commit_error:
|
|
62
|
+
try:
|
|
63
|
+
_rollback(
|
|
64
|
+
workspace_root,
|
|
65
|
+
snapshots,
|
|
66
|
+
created_directories=created_directories,
|
|
67
|
+
)
|
|
68
|
+
except Exception as rollback_error:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
f"patch commit failed: {commit_error}; rollback failed: {rollback_error}"
|
|
71
|
+
) from commit_error
|
|
72
|
+
raise
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def capture_text_states(paths) -> dict[Path, TextFileState]:
|
|
76
|
+
return {target: _snapshot(target) for target in paths}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_workspace_targets(workspace_root: Path, paths) -> None:
|
|
80
|
+
for target in paths:
|
|
81
|
+
target.relative_to(workspace_root)
|
|
82
|
+
_reject_symlink_parts(workspace_root, target)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _snapshot(target: Path) -> TextFileState:
|
|
86
|
+
if not target.exists():
|
|
87
|
+
return TextFileState(content=None, mode=0o600)
|
|
88
|
+
if not target.is_file():
|
|
89
|
+
raise ValueError("path is not a regular file")
|
|
90
|
+
return TextFileState(
|
|
91
|
+
content=target.read_text(encoding="utf-8"),
|
|
92
|
+
mode=stat.S_IMODE(target.stat().st_mode),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _rollback(
|
|
97
|
+
workspace_root: Path,
|
|
98
|
+
snapshots: dict[Path, TextFileState],
|
|
99
|
+
*,
|
|
100
|
+
created_directories: list[Path],
|
|
101
|
+
) -> None:
|
|
102
|
+
errors = []
|
|
103
|
+
for target, snapshot in reversed(tuple(snapshots.items())):
|
|
104
|
+
try:
|
|
105
|
+
_reject_symlink_parts(workspace_root, target)
|
|
106
|
+
if snapshot.content is None:
|
|
107
|
+
if target.exists():
|
|
108
|
+
target.unlink()
|
|
109
|
+
_fsync_directory(target.parent)
|
|
110
|
+
continue
|
|
111
|
+
_ensure_parent(workspace_root, target.parent, created_directories=[])
|
|
112
|
+
_atomic_write_text(target, snapshot.content, mode=snapshot.mode)
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
relative_path = target.relative_to(workspace_root).as_posix()
|
|
115
|
+
errors.append(f"{relative_path}: {exc}")
|
|
116
|
+
for directory in reversed(created_directories):
|
|
117
|
+
try:
|
|
118
|
+
directory.rmdir()
|
|
119
|
+
_fsync_directory(directory.parent)
|
|
120
|
+
except OSError:
|
|
121
|
+
continue
|
|
122
|
+
if errors:
|
|
123
|
+
raise RuntimeError("; ".join(errors))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _reject_symlink_parts(workspace_root: Path, target: Path) -> None:
|
|
127
|
+
current = workspace_root
|
|
128
|
+
for part in target.relative_to(workspace_root).parts:
|
|
129
|
+
current = current / part
|
|
130
|
+
if current.is_symlink():
|
|
131
|
+
raise ValueError("path must not be a symlink")
|
|
132
|
+
if not current.exists():
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _ensure_parent(
|
|
137
|
+
workspace_root: Path,
|
|
138
|
+
target_parent: Path,
|
|
139
|
+
*,
|
|
140
|
+
created_directories: list[Path],
|
|
141
|
+
) -> None:
|
|
142
|
+
current = workspace_root
|
|
143
|
+
for part in target_parent.relative_to(workspace_root).parts:
|
|
144
|
+
current = current / part
|
|
145
|
+
if current.is_symlink():
|
|
146
|
+
raise ValueError("path must not be a symlink")
|
|
147
|
+
if current.exists():
|
|
148
|
+
if not current.is_dir():
|
|
149
|
+
raise ValueError("parent path is not a directory")
|
|
150
|
+
continue
|
|
151
|
+
current.mkdir()
|
|
152
|
+
created_directories.append(current)
|
|
153
|
+
_fsync_directory(current.parent)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _atomic_write_text(
|
|
157
|
+
target: Path,
|
|
158
|
+
content: str,
|
|
159
|
+
*,
|
|
160
|
+
mode: int | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
resolved_mode = mode
|
|
163
|
+
if resolved_mode is None:
|
|
164
|
+
resolved_mode = stat.S_IMODE(target.stat().st_mode) if target.exists() else 0o600
|
|
165
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
166
|
+
prefix=f".{target.name}.",
|
|
167
|
+
suffix=".tmp",
|
|
168
|
+
dir=target.parent,
|
|
169
|
+
)
|
|
170
|
+
temporary_path = Path(temporary_name)
|
|
171
|
+
try:
|
|
172
|
+
os.chmod(temporary_path, resolved_mode)
|
|
173
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
|
|
174
|
+
descriptor = -1
|
|
175
|
+
handle.write(content)
|
|
176
|
+
handle.flush()
|
|
177
|
+
os.fsync(handle.fileno())
|
|
178
|
+
temporary_path.replace(target)
|
|
179
|
+
_fsync_directory(target.parent)
|
|
180
|
+
except Exception:
|
|
181
|
+
if descriptor != -1:
|
|
182
|
+
os.close(descriptor)
|
|
183
|
+
temporary_path.unlink(missing_ok=True)
|
|
184
|
+
raise
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _fsync_directory(directory: Path) -> None:
|
|
188
|
+
flags = os.O_RDONLY
|
|
189
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
190
|
+
flags |= os.O_DIRECTORY
|
|
191
|
+
descriptor = os.open(directory, flags)
|
|
192
|
+
try:
|
|
193
|
+
os.fsync(descriptor)
|
|
194
|
+
finally:
|
|
195
|
+
os.close(descriptor)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Iterable, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RuntimeHookDecision:
|
|
9
|
+
status: str
|
|
10
|
+
reason: str = ""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def allow(cls) -> "RuntimeHookDecision":
|
|
14
|
+
return cls(status="allowed")
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def deny(cls, reason: str) -> "RuntimeHookDecision":
|
|
18
|
+
return cls(status="denied", reason=reason or "runtime hook denied execution")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RuntimeHook(Protocol):
|
|
22
|
+
def on_run_start(self, context: Dict[str, Any]) -> None:
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def before_tool(self, context: Dict[str, Any]) -> RuntimeHookDecision:
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
def after_tool(self, context: Dict[str, Any]) -> None:
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def on_run_end(self, context: Dict[str, Any]) -> None:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RuntimeHookChain:
|
|
36
|
+
def __init__(self, hooks: Iterable[Any] = ()) -> None:
|
|
37
|
+
self._hooks = tuple(hooks)
|
|
38
|
+
|
|
39
|
+
def __bool__(self) -> bool:
|
|
40
|
+
return bool(self._hooks)
|
|
41
|
+
|
|
42
|
+
def on_run_start(self, context: Dict[str, Any]) -> None:
|
|
43
|
+
for hook in self._hooks:
|
|
44
|
+
handler = getattr(hook, "on_run_start", None)
|
|
45
|
+
if callable(handler):
|
|
46
|
+
handler(dict(context))
|
|
47
|
+
|
|
48
|
+
def before_tool(self, context: Dict[str, Any]) -> RuntimeHookDecision:
|
|
49
|
+
for hook in self._hooks:
|
|
50
|
+
handler = getattr(hook, "before_tool", None)
|
|
51
|
+
if not callable(handler):
|
|
52
|
+
continue
|
|
53
|
+
decision = handler(dict(context))
|
|
54
|
+
if decision is None:
|
|
55
|
+
continue
|
|
56
|
+
if not isinstance(decision, RuntimeHookDecision):
|
|
57
|
+
raise ValueError("runtime hook before_tool must return RuntimeHookDecision")
|
|
58
|
+
if decision.status == "denied":
|
|
59
|
+
return decision
|
|
60
|
+
if decision.status != "allowed":
|
|
61
|
+
raise ValueError("runtime hook decision status must be allowed or denied")
|
|
62
|
+
return RuntimeHookDecision.allow()
|
|
63
|
+
|
|
64
|
+
def after_tool(self, context: Dict[str, Any]) -> None:
|
|
65
|
+
for hook in self._hooks:
|
|
66
|
+
handler = getattr(hook, "after_tool", None)
|
|
67
|
+
if callable(handler):
|
|
68
|
+
handler(dict(context))
|
|
69
|
+
|
|
70
|
+
def on_run_end(self, context: Dict[str, Any]) -> None:
|
|
71
|
+
for hook in self._hooks:
|
|
72
|
+
handler = getattr(hook, "on_run_end", None)
|
|
73
|
+
if callable(handler):
|
|
74
|
+
handler(dict(context))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, Tuple
|
|
5
|
+
|
|
6
|
+
MAX_RUNTIME_TAGS = 16
|
|
7
|
+
MAX_RUNTIME_TAG_CHARS = 64
|
|
8
|
+
MAX_RUNTIME_METADATA_ENTRIES = 16
|
|
9
|
+
MAX_RUNTIME_METADATA_KEY_CHARS = 64
|
|
10
|
+
MAX_RUNTIME_METADATA_VALUE_CHARS = 256
|
|
11
|
+
|
|
12
|
+
_SAFE_KEY_CHARS = frozenset(
|
|
13
|
+
"abcdefghijklmnopqrstuvwxyz"
|
|
14
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
15
|
+
"0123456789"
|
|
16
|
+
"._:-"
|
|
17
|
+
)
|
|
18
|
+
_SECRET_KEY_PARTS = (
|
|
19
|
+
"api_key",
|
|
20
|
+
"apikey",
|
|
21
|
+
"authorization",
|
|
22
|
+
"bearer",
|
|
23
|
+
"password",
|
|
24
|
+
"secret",
|
|
25
|
+
"token",
|
|
26
|
+
)
|
|
27
|
+
_API_KEY_VALUE_PATTERN = re.compile(r"\bsk-[A-Za-z0-9:_-]{8,}\b")
|
|
28
|
+
_BEARER_VALUE_PATTERN = re.compile(
|
|
29
|
+
r"\b(Authorization:\s*Bearer\s+|Bearer\s+)[A-Za-z0-9._~+/:-]{8,}",
|
|
30
|
+
re.IGNORECASE,
|
|
31
|
+
)
|
|
32
|
+
_URL_CREDENTIAL_VALUE_PATTERN = re.compile(r"\bhttps?://[^/\s:@]+:[^/\s@]+@")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_runtime_metadata(
|
|
36
|
+
metadata: Any,
|
|
37
|
+
) -> Tuple[Dict[str, str], str]:
|
|
38
|
+
if metadata is None:
|
|
39
|
+
return {}, ""
|
|
40
|
+
if not isinstance(metadata, dict):
|
|
41
|
+
return {}, "metadata must be a JSON object"
|
|
42
|
+
if len(metadata) > MAX_RUNTIME_METADATA_ENTRIES:
|
|
43
|
+
return {}, (
|
|
44
|
+
f"metadata must contain at most {MAX_RUNTIME_METADATA_ENTRIES} entries"
|
|
45
|
+
)
|
|
46
|
+
normalized = {}
|
|
47
|
+
for raw_key, raw_value in metadata.items():
|
|
48
|
+
if not isinstance(raw_key, str):
|
|
49
|
+
return {}, "metadata keys must be strings"
|
|
50
|
+
key = raw_key.strip()
|
|
51
|
+
if not key:
|
|
52
|
+
return {}, "metadata keys must be non-empty strings"
|
|
53
|
+
if len(key) > MAX_RUNTIME_METADATA_KEY_CHARS:
|
|
54
|
+
return {}, (
|
|
55
|
+
f"metadata keys must contain at most "
|
|
56
|
+
f"{MAX_RUNTIME_METADATA_KEY_CHARS} characters"
|
|
57
|
+
)
|
|
58
|
+
if any(character not in _SAFE_KEY_CHARS for character in key):
|
|
59
|
+
return {}, "metadata keys may only contain letters, numbers, . _ : and -"
|
|
60
|
+
lowered_key = key.lower()
|
|
61
|
+
if any(secret_part in lowered_key for secret_part in _SECRET_KEY_PARTS):
|
|
62
|
+
return {}, "metadata must not contain secret-like keys"
|
|
63
|
+
if not isinstance(raw_value, str):
|
|
64
|
+
return {}, "metadata values must be strings"
|
|
65
|
+
value = raw_value.strip()
|
|
66
|
+
if len(value) > MAX_RUNTIME_METADATA_VALUE_CHARS:
|
|
67
|
+
return {}, (
|
|
68
|
+
f"metadata values must contain at most "
|
|
69
|
+
f"{MAX_RUNTIME_METADATA_VALUE_CHARS} characters"
|
|
70
|
+
)
|
|
71
|
+
if not _safe_runtime_label_value(value):
|
|
72
|
+
return {}, "metadata values must not contain control characters"
|
|
73
|
+
if _secret_like_runtime_label_value(value):
|
|
74
|
+
return {}, "metadata values must not contain secret-like values"
|
|
75
|
+
normalized[key] = value
|
|
76
|
+
return {key: normalized[key] for key in sorted(normalized)}, ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def validate_runtime_tags(tags: Any) -> Tuple[list[str], str]:
|
|
80
|
+
if tags is None:
|
|
81
|
+
return [], ""
|
|
82
|
+
if not isinstance(tags, list):
|
|
83
|
+
return [], "tags must be an array of strings"
|
|
84
|
+
if len(tags) > MAX_RUNTIME_TAGS:
|
|
85
|
+
return [], f"tags must contain at most {MAX_RUNTIME_TAGS} entries"
|
|
86
|
+
normalized = []
|
|
87
|
+
seen = set()
|
|
88
|
+
for raw_tag in tags:
|
|
89
|
+
if not isinstance(raw_tag, str):
|
|
90
|
+
return [], "tags must be an array of strings"
|
|
91
|
+
tag = raw_tag.strip()
|
|
92
|
+
if not tag:
|
|
93
|
+
return [], "tags must contain non-empty strings"
|
|
94
|
+
if len(tag) > MAX_RUNTIME_TAG_CHARS:
|
|
95
|
+
return [], (
|
|
96
|
+
f"tags must contain strings of at most "
|
|
97
|
+
f"{MAX_RUNTIME_TAG_CHARS} characters"
|
|
98
|
+
)
|
|
99
|
+
if not _safe_runtime_label_value(tag):
|
|
100
|
+
return [], "tags must not contain control characters"
|
|
101
|
+
if tag not in seen:
|
|
102
|
+
seen.add(tag)
|
|
103
|
+
normalized.append(tag)
|
|
104
|
+
return sorted(normalized), ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _safe_runtime_label_value(value: str) -> bool:
|
|
108
|
+
return all(ord(character) >= 32 for character in value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _secret_like_runtime_label_value(value: str) -> bool:
|
|
112
|
+
return bool(
|
|
113
|
+
_API_KEY_VALUE_PATTERN.search(value)
|
|
114
|
+
or _BEARER_VALUE_PATTERN.search(value)
|
|
115
|
+
or _URL_CREDENTIAL_VALUE_PATTERN.search(value)
|
|
116
|
+
)
|