noah-code 0.1.0__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.
@@ -0,0 +1,142 @@
1
+ """Native macOS containment for NOOA's forked CodeAct worker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import ctypes
7
+ import ctypes.util
8
+ import json
9
+ import os
10
+ import resource
11
+ from collections.abc import Iterable
12
+ from multiprocessing.connection import Connection
13
+ from typing import Any
14
+
15
+
16
+ class MacOSSandboxUnavailable(RuntimeError):
17
+ """Raised when the native macOS sandbox cannot be installed."""
18
+
19
+
20
+ def _profile_path(path: str) -> str:
21
+ """Quote an absolute path as a sandbox profile string literal."""
22
+ return json.dumps(os.path.abspath(os.path.expanduser(path)))
23
+
24
+
25
+ def build_macos_profile(read_paths: Iterable[str]) -> str:
26
+ """Build a deny-by-default profile with read-only interpreter access.
27
+
28
+ File metadata remains visible so Python can resolve imports and symlinks,
29
+ while file contents are readable only below explicitly trusted runtime
30
+ paths. The active repository is intentionally absent: workspace access must
31
+ cross the parent-side approval broker.
32
+ """
33
+ roots: set[str] = set()
34
+ for path in read_paths:
35
+ if not path:
36
+ continue
37
+ absolute = os.path.abspath(os.path.expanduser(path))
38
+ resolved = os.path.realpath(absolute)
39
+ if os.path.exists(resolved):
40
+ # macOS sandbox profiles match the path used by the operation, not
41
+ # only its canonical target. Keep both sides of symlinks such as
42
+ # /tmp -> /private/tmp and uv's versioned Python aliases.
43
+ roots.update({absolute, resolved})
44
+ roots.update(
45
+ path
46
+ for path in (
47
+ "/System/Library",
48
+ "/usr/lib",
49
+ "/private/var/db/dyld",
50
+ )
51
+ if os.path.exists(path)
52
+ )
53
+ ancestors: set[str] = {"/"}
54
+ for root in roots:
55
+ parent = os.path.dirname(root)
56
+ while parent and parent not in ancestors:
57
+ ancestors.add(parent)
58
+ next_parent = os.path.dirname(parent)
59
+ if next_parent == parent:
60
+ break
61
+ parent = next_parent
62
+ metadata_rules = "\n".join(
63
+ f" (literal {_profile_path(path)})" for path in sorted(ancestors)
64
+ )
65
+ read_rules = "\n".join(f" (subpath {_profile_path(path)})" for path in sorted(roots))
66
+ return f"""(version 1)
67
+ (deny default)
68
+ (allow file-read-metadata
69
+ {metadata_rules}
70
+ {read_rules}
71
+ (literal \"/dev/null\"))
72
+ (allow file-read-data
73
+ {read_rules}
74
+ (literal \"/dev/null\"))
75
+ (allow file-write-data (literal \"/dev/null\"))
76
+ (allow file-ioctl (literal \"/dev/null\"))
77
+ (allow sysctl-read)
78
+ (allow mach-lookup)
79
+ (allow ipc-posix-shm)
80
+ (allow signal (target self))
81
+ """
82
+
83
+
84
+ def _install_native_sandbox(profile: str) -> None:
85
+ library = ctypes.util.find_library("sandbox")
86
+ if not library:
87
+ raise MacOSSandboxUnavailable("libsandbox is not available")
88
+ sandbox = ctypes.CDLL(library)
89
+ sandbox.sandbox_init.argtypes = [
90
+ ctypes.c_char_p,
91
+ ctypes.c_uint64,
92
+ ctypes.POINTER(ctypes.c_char_p),
93
+ ]
94
+ sandbox.sandbox_init.restype = ctypes.c_int
95
+ error = ctypes.c_char_p()
96
+ result = sandbox.sandbox_init(profile.encode(), 0, ctypes.byref(error))
97
+ if result == 0:
98
+ return
99
+ message = error.value.decode(errors="replace") if error.value else "unknown error"
100
+ free_error = getattr(sandbox, "sandbox_free_error", None)
101
+ if free_error is not None and error.value:
102
+ free_error(error)
103
+ raise MacOSSandboxUnavailable(f"sandbox_init failed: {message}")
104
+
105
+
106
+ def _apply_resource_limits(*, max_memory_mb: int, max_cpu_seconds: int) -> None:
107
+ # macOS maps shared regions into a normal Python process at virtual sizes
108
+ # far beyond RLIMIT_AS, while RLIMIT_DATA cannot be lowered reliably below
109
+ # the inherited process footprint. Keep the parent-enforced wall timeout
110
+ # and CPU limit; the argument remains explicit so this difference cannot be
111
+ # mistaken for Linux's enforceable address-space cap.
112
+ _ = max_memory_mb
113
+ if max_cpu_seconds > 0:
114
+ limit = max_cpu_seconds
115
+ _, hard = resource.getrlimit(resource.RLIMIT_CPU)
116
+ if hard != resource.RLIM_INFINITY:
117
+ limit = min(limit, hard)
118
+ resource.setrlimit(resource.RLIMIT_CPU, (limit, limit))
119
+
120
+
121
+ def macos_worker_main(
122
+ conn: Connection,
123
+ init: dict[str, Any],
124
+ profile: str,
125
+ max_memory_mb: int,
126
+ max_cpu_seconds: int,
127
+ ) -> None: # pragma: no cover - executed in a forked worker
128
+ """Install irreversible guards, then enter NOOA's normal worker loop."""
129
+ try:
130
+ _apply_resource_limits(
131
+ max_memory_mb=max_memory_mb,
132
+ max_cpu_seconds=max_cpu_seconds,
133
+ )
134
+ _install_native_sandbox(profile)
135
+ except BaseException as exc: # noqa: BLE001 - child must fail closed
136
+ with contextlib.suppress(Exception):
137
+ conn.send({"type": "fatal", "error": f"{type(exc).__name__}: {exc}"})
138
+ os._exit(3)
139
+
140
+ from nooa.runtime.sandbox.worker import worker_main
141
+
142
+ worker_main(conn, init)
noah_code/mcp_setup.py ADDED
@@ -0,0 +1,91 @@
1
+ """Optional MCP server attachment for CodingAgent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from noah_code.approvals import ApprovalBroker
9
+ from noah_code.config import NoahCodeConfig
10
+ from noah_code.permissions import PermissionCategory, PermissionEngine
11
+
12
+
13
+ def mcp_config_paths(workspace: Path) -> list[Path]:
14
+ return [
15
+ workspace / ".mcp.json",
16
+ workspace / ".noah-code" / "mcp.json",
17
+ Path.home() / ".config" / "noah-code" / "mcp.json",
18
+ ]
19
+
20
+
21
+ async def install_mcp(
22
+ agent: Any,
23
+ workspace: Path,
24
+ config: NoahCodeConfig,
25
+ *,
26
+ engine: PermissionEngine,
27
+ approvals: ApprovalBroker,
28
+ ) -> str:
29
+ """Attach configured MCP servers as agent attributes when available.
30
+
31
+ Permission category ``mcp`` is checked before first use via a thin wrapper
32
+ only if we can gate at attach time; otherwise servers are attached and
33
+ documented as requiring the mcp permission policy.
34
+ """
35
+ servers = dict(config.mcp.get("servers") or {})
36
+ mcp_file: Path | None = None
37
+ for path in mcp_config_paths(workspace):
38
+ if path.is_file():
39
+ mcp_file = path
40
+ break
41
+
42
+ if not servers and mcp_file is None:
43
+ return "mcp: none configured"
44
+
45
+ try:
46
+ from nooa.mcp import MCPManager
47
+ except ImportError:
48
+ return "mcp: nooa[mcp] not installed"
49
+
50
+ decision = engine.decide(PermissionCategory.MCP, "*")
51
+ if decision.denied:
52
+ return f"mcp: denied ({decision.reason})"
53
+ if decision.needs_ask:
54
+ try:
55
+ await approvals.require(decision)
56
+ except PermissionError as exc:
57
+ return f"mcp: {exc}"
58
+
59
+ attached: list[str] = []
60
+ try:
61
+ names = list(servers.keys()) if servers else MCPManager.list_servers(mcp_file=mcp_file)
62
+ except Exception as exc: # noqa: BLE001
63
+ return f"mcp: list failed ({exc})"
64
+
65
+ for name in names:
66
+ try:
67
+ spec = servers.get(name, {})
68
+ tool = MCPManager.create_from_server(
69
+ name,
70
+ mcp_file=mcp_file,
71
+ **{k: v for k, v in spec.items() if k != "name"},
72
+ )
73
+ # Sanitize attribute name.
74
+ attr = re_attr(name)
75
+ setattr(agent, attr, tool)
76
+ approved = getattr(agent, "_sandbox_approved_roots", None)
77
+ if isinstance(approved, set):
78
+ approved.add(attr)
79
+ attached.append(attr)
80
+ except Exception as exc: # noqa: BLE001
81
+ return f"mcp: attached={attached} error on {name}: {exc}"
82
+ return f"mcp: attached={attached}"
83
+
84
+
85
+ def re_attr(name: str) -> str:
86
+ import re
87
+
88
+ cleaned = re.sub(r"[^a-zA-Z0-9_]", "_", name)
89
+ if cleaned and cleaned[0].isdigit():
90
+ cleaned = f"mcp_{cleaned}"
91
+ return cleaned or "mcp_server"
@@ -0,0 +1,400 @@
1
+ """Deterministic allow/ask/deny permission engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ import shlex
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from noah_code.config import PermissionRule
14
+
15
+ PermissionAction = Literal["allow", "ask", "deny"]
16
+
17
+
18
+ class PermissionCategory(StrEnum):
19
+ READ = "read"
20
+ EDIT = "edit"
21
+ BASH = "bash"
22
+ EXTERNAL_DIRECTORY = "external_directory"
23
+ TASK = "task"
24
+ SKILL = "skill"
25
+ MCP = "mcp"
26
+ LSP = "lsp"
27
+
28
+
29
+ # Patterns that are always denied regardless of mode / auto.
30
+ _ALWAYS_DENY_BASH = (
31
+ re.compile(r"\brm\s+(-[^\s]*\s+)*-?[rR]?[fF]?[rR]?[fF]?\s+(/|\.|~|\*)"),
32
+ re.compile(r"\brm\s+.*\s+(-[^\s]*r|-rf|-fr)\b"),
33
+ re.compile(r"\b(mkfs|dd\s+if=|/dev/sd|/dev/disk|shred\b|wipefs\b)\b"),
34
+ re.compile(r":\(\)\s*\{\s*:\|:\s*&\s*\}\s*;:"), # fork bomb
35
+ re.compile(r"\bgit\s+push\b"),
36
+ re.compile(r"\bgit\s+clean\b"),
37
+ re.compile(r"\bgit\s+reset\s+--hard\b"),
38
+ re.compile(r"\bgit\s+filter-branch\b"),
39
+ re.compile(r"\bprintenv\b"),
40
+ re.compile(r"\benv\b(?!\s+\w+=)"),
41
+ re.compile(r"\bexport\s+-p\b"),
42
+ re.compile(r"\bcat\s+.*\.pem\b"),
43
+ re.compile(r"\bcat\s+.*id_rsa\b"),
44
+ re.compile(r"\bchmod\s+-R\s+777\b"),
45
+ re.compile(r"\bcurl\b.*\|\s*(ba)?sh\b"),
46
+ re.compile(r"\bwget\b.*\|\s*(ba)?sh\b"),
47
+ re.compile(r"\bsudo\b"),
48
+ re.compile(r"\bchmod\b.+\s+/"),
49
+ re.compile(r"\bchown\b.+\s+/"),
50
+ )
51
+
52
+ # Mutating patterns that always require ask (even if a broad allow matched earlier
53
+ # via auto) unless already an explicit session allow - handled by forcing ask
54
+ # when compound/uncertain; these bump deny-adjacent risk to ask minimum.
55
+ _ALWAYS_ASK_BASH = (
56
+ re.compile(r"\brm\b"),
57
+ re.compile(r"\bmv\b"),
58
+ re.compile(r"\bchmod\b"),
59
+ re.compile(r"\bchown\b"),
60
+ re.compile(r"\bkill\b"),
61
+ re.compile(r"\bpkill\b"),
62
+ re.compile(r"\bdocker\s+(rm|rmi|system\s+prune)\b"),
63
+ re.compile(r"\bnpm\s+(publish|unpublish)\b"),
64
+ re.compile(r"\bpip\s+install\b"),
65
+ re.compile(r"\buv\s+pip\s+install\b"),
66
+ re.compile(r"\bcurl\b"),
67
+ re.compile(r"\bwget\b"),
68
+ )
69
+
70
+ _MUTATING_GIT = re.compile(
71
+ r"\bgit\s+(commit|add|push|pull|fetch|rebase|merge|reset|clean|checkout|stash|tag|remote)\b"
72
+ )
73
+ _READ_ONLY_PREFIXES = (
74
+ "git status",
75
+ "git diff",
76
+ "git log",
77
+ "git show",
78
+ "git branch",
79
+ "git rev-parse",
80
+ "rg ",
81
+ "grep ",
82
+ "egrep ",
83
+ "fgrep ",
84
+ "find ",
85
+ "ls ",
86
+ "pwd",
87
+ "head ",
88
+ "tail ",
89
+ "wc ",
90
+ "file ",
91
+ "stat ",
92
+ "test ",
93
+ "pytest --collect-only",
94
+ "python -m pytest --collect-only",
95
+ )
96
+ _READ_ONLY_GIT_SUBCOMMANDS = frozenset({"branch", "diff", "log", "rev-parse", "show", "status"})
97
+
98
+ _SECRET_BASENAMES = {
99
+ ".env",
100
+ ".env.local",
101
+ ".env.production",
102
+ ".env.development",
103
+ "credentials.json",
104
+ "service-account.json",
105
+ "id_rsa",
106
+ "id_ed25519",
107
+ "id_ecdsa",
108
+ }
109
+ _SECRET_SUFFIXES = (".pem", ".key", ".p12", ".pfx")
110
+ _SECRET_ALLOW = {".env.example", ".env.sample", ".env.template"}
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class PermissionDecision:
115
+ category: str
116
+ target: str
117
+ action: PermissionAction
118
+ matching_rule: PermissionRule | None
119
+ reason: str
120
+ remember_pattern: str
121
+
122
+ @property
123
+ def allowed(self) -> bool:
124
+ return self.action == "allow"
125
+
126
+ @property
127
+ def denied(self) -> bool:
128
+ return self.action == "deny"
129
+
130
+ @property
131
+ def needs_ask(self) -> bool:
132
+ return self.action == "ask"
133
+
134
+
135
+ def is_secret_path(path: str | Path) -> bool:
136
+ p = Path(path)
137
+ name = p.name
138
+ if name in _SECRET_ALLOW:
139
+ return False
140
+ if name in _SECRET_BASENAMES:
141
+ return True
142
+ if name.startswith(".env.") and name not in _SECRET_ALLOW:
143
+ return True
144
+ if any(name.endswith(suf) for suf in _SECRET_SUFFIXES):
145
+ return True
146
+ if "id_rsa" in name or "id_ed25519" in name:
147
+ return True
148
+ parts = p.parts
149
+ if ".git" in parts:
150
+ return True
151
+ return name.endswith(".db") and "noah-code" in str(p)
152
+
153
+
154
+ def _match_rule(rule: PermissionRule, category: str, target: str) -> bool:
155
+ cat_ok = rule.category in {"*", category}
156
+ if not cat_ok:
157
+ return False
158
+ return fnmatch.fnmatch(target, rule.pattern) or fnmatch.fnmatch(Path(target).name, rule.pattern)
159
+
160
+
161
+ class PermissionEngine:
162
+ """Ordered wildcard rules; last matching rule wins."""
163
+
164
+ def __init__(
165
+ self,
166
+ rules: list[PermissionRule] | None = None,
167
+ *,
168
+ mode: Literal["build", "plan"] = "build",
169
+ auto_approve: bool = False,
170
+ ) -> None:
171
+ self.rules: list[PermissionRule] = list(rules or [])
172
+ self.mode = mode
173
+ self.auto_approve = auto_approve
174
+ self._session_rules: list[PermissionRule] = []
175
+
176
+ def add_session_rule(self, rule: PermissionRule) -> None:
177
+ self._session_rules.append(rule)
178
+
179
+ def snapshot_session_rules(self) -> list[dict]:
180
+ return [r.model_dump() for r in self._session_rules]
181
+
182
+ def load_session_rules(self, raw: list[dict] | None) -> None:
183
+ self._session_rules = [PermissionRule.model_validate(r) for r in (raw or [])]
184
+
185
+ def decide(self, category: str, target: str) -> PermissionDecision:
186
+ normalized = target.strip() or "*"
187
+ # Hard denies for secrets on read/edit.
188
+ if (
189
+ category in {PermissionCategory.READ, PermissionCategory.EDIT}
190
+ and is_secret_path(normalized)
191
+ and Path(normalized).name not in _SECRET_ALLOW
192
+ ):
193
+ return PermissionDecision(
194
+ category=category,
195
+ target=normalized,
196
+ action="deny",
197
+ matching_rule=None,
198
+ reason="secret or credential path denied",
199
+ remember_pattern=normalized,
200
+ )
201
+
202
+ if category == PermissionCategory.BASH:
203
+ hard = self._hard_bash_deny(normalized)
204
+ if hard is not None:
205
+ return hard
206
+
207
+ if self.mode == "plan":
208
+ plan = self._plan_mode_gate(category, normalized)
209
+ if plan is not None:
210
+ return plan
211
+
212
+ matching: PermissionRule | None = None
213
+ for rule in [*self.rules, *self._session_rules]:
214
+ if _match_rule(rule, category, normalized):
215
+ matching = rule
216
+
217
+ if matching is None:
218
+ action: PermissionAction = "ask"
219
+ reason = "no matching rule; default ask"
220
+ else:
221
+ action = matching.action
222
+ reason = matching.reason or f"matched {matching.pattern}"
223
+
224
+ if category == PermissionCategory.BASH and action == "allow":
225
+ elevated = self._elevated_bash_ask(normalized)
226
+ if elevated is not None:
227
+ action = elevated.action
228
+ reason = elevated.reason
229
+ matching = elevated.matching_rule
230
+
231
+ if action == "ask" and self.auto_approve:
232
+ # --auto never overrides explicit deny; only ask → allow.
233
+ action = "allow"
234
+ reason = f"{reason} (auto-approved)"
235
+
236
+ return PermissionDecision(
237
+ category=category,
238
+ target=normalized,
239
+ action=action,
240
+ matching_rule=matching,
241
+ reason=reason,
242
+ remember_pattern=self._remember_pattern(category, normalized),
243
+ )
244
+
245
+ def _remember_pattern(self, category: str, target: str) -> str:
246
+ if category == PermissionCategory.BASH:
247
+ try:
248
+ parts = shlex.split(target)
249
+ except ValueError:
250
+ parts = target.split()
251
+ if parts:
252
+ return f"{parts[0]} *"
253
+ return "*"
254
+ return target
255
+
256
+ def _hard_bash_deny(self, command: str) -> PermissionDecision | None:
257
+ try:
258
+ tokens = shlex.split(command)
259
+ except ValueError:
260
+ tokens = []
261
+
262
+ for index, token in enumerate(tokens):
263
+ if Path(token).name != "git":
264
+ continue
265
+ remaining = tokens[index + 1 :]
266
+ if any(part in {"push", "clean", "filter-branch"} for part in remaining) or (
267
+ "reset" in remaining and "--hard" in remaining
268
+ ):
269
+ return PermissionDecision(
270
+ category=PermissionCategory.BASH,
271
+ target=command,
272
+ action="deny",
273
+ matching_rule=None,
274
+ reason="destructive git command denied",
275
+ remember_pattern=command,
276
+ )
277
+ if self.auto_approve and not self.is_readonly_command(command):
278
+ return PermissionDecision(
279
+ category=PermissionCategory.BASH,
280
+ target=command,
281
+ action="deny",
282
+ matching_rule=None,
283
+ reason="mutating or unrecognized git commands are not auto-approved",
284
+ remember_pattern=command,
285
+ )
286
+
287
+ for pat in _ALWAYS_DENY_BASH:
288
+ if pat.search(command):
289
+ return PermissionDecision(
290
+ category=PermissionCategory.BASH,
291
+ target=command,
292
+ action="deny",
293
+ matching_rule=None,
294
+ reason="destructive or secret-exposing command denied",
295
+ remember_pattern=command,
296
+ )
297
+ lowered = command.lower()
298
+ if "aws_secret" in lowered or "private key" in lowered:
299
+ return PermissionDecision(
300
+ category=PermissionCategory.BASH,
301
+ target=command,
302
+ action="deny",
303
+ matching_rule=None,
304
+ reason="credential dump denied",
305
+ remember_pattern=command,
306
+ )
307
+ return None
308
+
309
+ def _elevated_bash_ask(self, command: str) -> PermissionDecision | None:
310
+ """Force ask for risky commands unless a session allow rule already matched."""
311
+ session_allowed = any(
312
+ r.action == "allow" and _match_rule(r, PermissionCategory.BASH, command)
313
+ for r in self._session_rules
314
+ )
315
+ if session_allowed:
316
+ return None
317
+ for pat in _ALWAYS_ASK_BASH:
318
+ if pat.search(command):
319
+ return PermissionDecision(
320
+ category=PermissionCategory.BASH,
321
+ target=command,
322
+ action="ask",
323
+ matching_rule=None,
324
+ reason="elevated-risk shell command requires approval",
325
+ remember_pattern=self._remember_pattern(PermissionCategory.BASH, command),
326
+ )
327
+ return None
328
+
329
+ def _plan_mode_gate(self, category: str, target: str) -> PermissionDecision | None:
330
+ if category == PermissionCategory.EDIT:
331
+ return PermissionDecision(
332
+ category=category,
333
+ target=target,
334
+ action="deny",
335
+ matching_rule=None,
336
+ reason="plan mode forbids file edits",
337
+ remember_pattern=target,
338
+ )
339
+ if category == PermissionCategory.BASH and not self.is_readonly_command(target):
340
+ return PermissionDecision(
341
+ category=category,
342
+ target=target,
343
+ action="deny",
344
+ matching_rule=None,
345
+ reason="plan mode forbids mutating shell commands",
346
+ remember_pattern=target,
347
+ )
348
+ return None
349
+
350
+ @staticmethod
351
+ def is_readonly_command(command: str) -> bool:
352
+ cmd = command.strip()
353
+ if not cmd:
354
+ return True
355
+ if _is_compound(cmd):
356
+ return False
357
+ lowered = cmd.lower()
358
+ if _MUTATING_GIT.search(lowered):
359
+ return False
360
+ try:
361
+ tokens = shlex.split(cmd)
362
+ except ValueError:
363
+ return False
364
+ if not tokens:
365
+ return True
366
+ program = Path(tokens[0]).name.lower()
367
+ if program == "git":
368
+ return len(tokens) > 1 and tokens[1].lower() in _READ_ONLY_GIT_SUBCOMMANDS
369
+ if program == "pwd":
370
+ return len(tokens) == 1
371
+ return any(lowered == p.strip() or lowered.startswith(p) for p in _READ_ONLY_PREFIXES[6:])
372
+
373
+ @staticmethod
374
+ def is_uncertain_shell(command: str) -> bool:
375
+ return _is_compound(command)
376
+
377
+
378
+ def _is_compound(command: str) -> bool:
379
+ """Detect pipes, chains, redirects, substitutions, heredocs."""
380
+ # Rough conservative scan - not a full shell parser.
381
+ specials = ("|", "&&", "||", ";", "`", "$(", "${", ">", "<", "<<", "\n")
382
+ in_single = False
383
+ in_double = False
384
+ i = 0
385
+ while i < len(command):
386
+ ch = command[i]
387
+ if ch == "'" and not in_double:
388
+ in_single = not in_single
389
+ elif ch == '"' and not in_single:
390
+ in_double = not in_double
391
+ elif not in_single and not in_double:
392
+ for sp in specials:
393
+ if command.startswith(sp, i):
394
+ return True
395
+ i += 1
396
+ try:
397
+ shlex.split(command)
398
+ except ValueError:
399
+ return True
400
+ return False