synapse-orch-ai 1.9.0 → 1.9.2
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 +4 -0
- package/backend/core/config.py +10 -0
- package/backend/core/egress_guard.py +202 -0
- package/backend/core/expressions.py +522 -0
- package/backend/core/file_roots.py +80 -0
- package/backend/core/internal_auth.py +129 -20
- package/backend/core/mcp_client.py +62 -1
- package/backend/core/models.py +20 -2
- package/backend/core/orchestration/steps.py +38 -75
- package/backend/core/react_engine.py +8 -0
- package/backend/core/routes/settings.py +5 -1
- package/backend/core/routes/tools.py +28 -4
- package/backend/core/safe_fetch.py +106 -0
- package/backend/core/scale/worker_server_module.py +22 -1
- package/backend/core/server.py +42 -25
- package/backend/tools/code_search.py +23 -21
- package/backend/tools/file_reader.py +12 -2
- package/backend/tools/pdf_parser.py +5 -5
- package/backend/tools/sandbox.py +6 -2
- package/backend/tools/web_scraper.py +80 -0
- package/backend/tools/xlsx_parser.py +5 -5
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/api/agent-types/route.js +3 -2
- package/frontend-build/.next/server/app/api/agent-types/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js +3 -2
- package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/login/route.js +3 -2
- package/frontend-build/.next/server/app/api/auth/login/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/logout/route.js +2 -1
- package/frontend-build/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/status/route.js +3 -2
- package/frontend-build/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/builder/chat/route.js +3 -2
- package/frontend-build/.next/server/app/api/builder/chat/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/builder/resume/route.js +3 -2
- package/frontend-build/.next/server/app/api/builder/resume/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/chat/route.js +3 -2
- package/frontend-build/.next/server/app/api/chat/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/chat/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/chat/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js +3 -2
- package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/logs/[type]/route.js +3 -2
- package/frontend-build/.next/server/app/api/logs/[type]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/models/route.js +3 -2
- package/frontend-build/.next/server/app/api/models/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/notifications/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/icon.png/route.js +2 -1
- package/frontend-build/.next/server/app/icon.png/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0.-gycy._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0~bybkp._.js → [root-of-the-server]__003vgom._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t4a96i._.js → [root-of-the-server]__03ek9xq._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__07vf6z1._.js → [root-of-the-server]__03x0ii~._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0wqyl-j._.js → [root-of-the-server]__03zuar1._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0zl78ks._.js → [root-of-the-server]__04ks5f_._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t.cymd._.js → [root-of-the-server]__0_fafcz._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0_~_1-l._.js +10 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0fa0k5_._.js → [root-of-the-server]__0dbuutb._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0hnd6qu._.js → [root-of-the-server]__0hlm8c7._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0is-hlj._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0n-ayat._.js → [root-of-the-server]__0i~uoki._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0js9sik._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0ncun1_._.js +1 -1
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0i0g0o9._.js → [root-of-the-server]__0oe4zjm._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0av_50r._.js → [root-of-the-server]__0v4clie._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0w02yhg._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0wbbjuq._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0-0m2_n._.js → [root-of-the-server]__0wgri4w._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0xtvg9h._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__01abhvg._.js → [root-of-the-server]__0yuz2ug._.js} +2 -2
- package/frontend-build/.next/server/chunks/node_modules_next_124cnn1._.js +3 -0
- package/frontend-build/.next/server/chunks/node_modules_next_dist_12gp6q5._.js +6 -0
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/frontend-build/.next/static/chunks/{0a-yeaiijslpf.css → 13upr_rmz7sy8.css} +5 -5
- package/package.json +1 -1
- package/frontend-build/.next/server/chunks/[root-of-the-server]__08ei_wz._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0_qd3xa._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0aw6-s5._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0j8-xkl._.js +0 -13
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0uulfit._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__10fof-m._.js +0 -3
- /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""The small expression dialect that `if_else`, `switch` and `loop` steps speak.
|
|
2
|
+
|
|
3
|
+
Those steps each carry an expression written either by a person in the step
|
|
4
|
+
editor or by the builder model through `add_single_step`. Until this module
|
|
5
|
+
existed the strings went to CPython's `eval()` with `{'__builtins__': {}}` as
|
|
6
|
+
their only containment, which is not a sandbox in either direction:
|
|
7
|
+
`().__class__.__bases__[0].__subclasses__()` walks from an inline literal to
|
|
8
|
+
`Popen`, and a value pulled out of shared state reaches the same graph from the
|
|
9
|
+
other side — `state.note.__class__` needed no literal at all.
|
|
10
|
+
|
|
11
|
+
So an expression is parsed to a syntax tree and walked here instead.
|
|
12
|
+
`ast.parse(…, mode="eval")` is the only compile-family call in this file and it
|
|
13
|
+
returns a tree, never a code object; `eval`, `exec` and `compile()`-to-code are
|
|
14
|
+
not used and must not be added. Nothing is enforced by a list of dangerous
|
|
15
|
+
names, either: the walker knows the node types, the names and the methods it is
|
|
16
|
+
willing to evaluate and refuses everything else, so a construct nobody thought
|
|
17
|
+
of is refused by default rather than admitted by default.
|
|
18
|
+
|
|
19
|
+
The dialect, which is what every existing expression was written against:
|
|
20
|
+
|
|
21
|
+
state.result.flag == True dot navigation; a missing key is None
|
|
22
|
+
state.items[0] indexing, because lists come back raw
|
|
23
|
+
(state.n or 0) < 5 and / or / not, comparisons, arithmetic
|
|
24
|
+
str(state.status).lower() the eleven functions and the method table
|
|
25
|
+
state.tag in ('a', 'b') membership, literals, `x if y else z`
|
|
26
|
+
|
|
27
|
+
Refused, each with a reason a person can act on: f-strings, comprehensions,
|
|
28
|
+
lambdas and generator expressions, the walrus, bit operators, any name that is
|
|
29
|
+
not `state` or one of the eleven, any attribute beginning with an underscore,
|
|
30
|
+
and any method outside the table. `str.format` is excluded deliberately — the
|
|
31
|
+
attribute path in `"{0.__class__}".format(x)` lives inside a string where no
|
|
32
|
+
syntax tree can see it. So are the mutating methods: `state.items.append(x)`
|
|
33
|
+
used to rewrite persisted state from inside a routing condition.
|
|
34
|
+
|
|
35
|
+
There is no wall-clock timeout, and that is a decision rather than an omission.
|
|
36
|
+
With no comprehension, lambda or generator there is no unbounded-iteration
|
|
37
|
+
construct left in the grammar; the node cap and the three arithmetic guards
|
|
38
|
+
below close the remaining ways to spend real time. A timeout would need a
|
|
39
|
+
thread or `signal` — unusable off the main thread under FastAPI — and could not
|
|
40
|
+
preempt a single CPython bigint operation anyway.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import ast
|
|
46
|
+
import functools
|
|
47
|
+
import operator
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ExpressionRefused(ValueError):
|
|
53
|
+
"""The expression uses a construct this evaluator will not run.
|
|
54
|
+
|
|
55
|
+
Raised from the parse-and-check pass, before any value exists — which is
|
|
56
|
+
what lets `unsafe_reason()` answer at authoring time, with no run.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ExprResult:
|
|
62
|
+
"""`refused` separates "we will not run this" from "this blew up".
|
|
63
|
+
|
|
64
|
+
Both end the same way at every call site — a warning and the step's
|
|
65
|
+
documented default — but only the first is worth telling an author about
|
|
66
|
+
while they are still writing it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
value: Any
|
|
70
|
+
error: str | None = None
|
|
71
|
+
refused: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DotNavigableState:
|
|
75
|
+
"""Shared state, navigable with dots. A missing key is None, not an error.
|
|
76
|
+
|
|
77
|
+
Moved here from `core.orchestration.steps` when the evaluator stopped being
|
|
78
|
+
`eval()`. The semantics are deliberately unchanged, because every expression
|
|
79
|
+
anyone has written was written against them — including the ones that are
|
|
80
|
+
arguably wrong, like `state.d.keys()` failing because `keys` is looked up as
|
|
81
|
+
a state key first.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, data: dict):
|
|
85
|
+
object.__setattr__(self, '_data', data)
|
|
86
|
+
|
|
87
|
+
def __getattr__(self, name: str):
|
|
88
|
+
data = object.__getattribute__(self, '_data')
|
|
89
|
+
if name.startswith('_'):
|
|
90
|
+
raise AttributeError(name)
|
|
91
|
+
val = data.get(name)
|
|
92
|
+
if isinstance(val, dict):
|
|
93
|
+
return DotNavigableState(val)
|
|
94
|
+
return val
|
|
95
|
+
|
|
96
|
+
def __getitem__(self, key):
|
|
97
|
+
data = object.__getattribute__(self, '_data')
|
|
98
|
+
val = data.get(key)
|
|
99
|
+
if isinstance(val, dict):
|
|
100
|
+
return DotNavigableState(val)
|
|
101
|
+
return val
|
|
102
|
+
|
|
103
|
+
def __iter__(self):
|
|
104
|
+
# Without this, `list(state)` falls back to the old sequence protocol,
|
|
105
|
+
# calling __getitem__(0), __getitem__(1), … — each of which returns None
|
|
106
|
+
# instead of raising IndexError, so the iteration never terminates. A
|
|
107
|
+
# dict's own meaning (iterate the keys) is both the obvious one and a
|
|
108
|
+
# finite one.
|
|
109
|
+
return iter(object.__getattribute__(self, '_data'))
|
|
110
|
+
|
|
111
|
+
def __repr__(self):
|
|
112
|
+
return repr(object.__getattribute__(self, '_data'))
|
|
113
|
+
|
|
114
|
+
def __str__(self):
|
|
115
|
+
return str(object.__getattribute__(self, '_data'))
|
|
116
|
+
|
|
117
|
+
def __eq__(self, other):
|
|
118
|
+
data = object.__getattribute__(self, '_data')
|
|
119
|
+
if isinstance(other, DotNavigableState):
|
|
120
|
+
return data == object.__getattribute__(other, '_data')
|
|
121
|
+
return data == other
|
|
122
|
+
|
|
123
|
+
def __bool__(self):
|
|
124
|
+
return bool(object.__getattribute__(self, '_data'))
|
|
125
|
+
|
|
126
|
+
def __contains__(self, item):
|
|
127
|
+
return item in object.__getattribute__(self, '_data')
|
|
128
|
+
|
|
129
|
+
def __len__(self):
|
|
130
|
+
return len(object.__getattribute__(self, '_data'))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _raw(value: Any) -> Any:
|
|
134
|
+
"""Unwrap, so a wrapper can never escape into shared state.
|
|
135
|
+
|
|
136
|
+
`switch` writes its expression's value to `output_key`, and shared state is
|
|
137
|
+
`json.dumps`-ed at every checkpoint — so `switch_expression: "state.llm_json"`
|
|
138
|
+
(one keystroke from a real one) used to park an unserialisable object in the
|
|
139
|
+
run and fail the checkpoint several steps later.
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(value, DotNavigableState):
|
|
142
|
+
return object.__getattribute__(value, '_data')
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── What the dialect contains ────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
_STATE = 'state'
|
|
149
|
+
|
|
150
|
+
#: The eleven callables, carried over unchanged. The `'None'/'True'/'False'`
|
|
151
|
+
#: entries the old namespace also held were dead weight: since 3.8 those parse
|
|
152
|
+
#: to `ast.Constant` and are never looked up as names.
|
|
153
|
+
_FUNCS = {
|
|
154
|
+
'len': len, 'int': int, 'float': float, 'str': str,
|
|
155
|
+
'bool': bool, 'list': list, 'dict': dict, 'abs': abs,
|
|
156
|
+
'min': min, 'max': max, 'round': round,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#: Keyed by (exact type, name) and holding the *unbound* function, which is what
|
|
160
|
+
#: makes this a boundary rather than a suggestion: an object that merely has a
|
|
161
|
+
#: `.get` is not a dict and finds no entry, and a `str` subclass that overrides
|
|
162
|
+
#: `lower` does not get to run its override. Nothing here calls `getattr` on a
|
|
163
|
+
#: value, so no `__getattr__` or descriptor in shared state can fire either.
|
|
164
|
+
_METHODS: dict[tuple[type, str], Any] = {
|
|
165
|
+
(t, name): getattr(t, name)
|
|
166
|
+
for t, names in (
|
|
167
|
+
(str, (
|
|
168
|
+
'lower upper strip lstrip rstrip startswith endswith split rsplit '
|
|
169
|
+
'join replace count find title capitalize casefold isdigit isalpha '
|
|
170
|
+
'isnumeric removeprefix removesuffix'
|
|
171
|
+
).split()),
|
|
172
|
+
(dict, ('get', 'keys', 'values', 'items')),
|
|
173
|
+
(list, ('index', 'count')),
|
|
174
|
+
(tuple, ('index', 'count')),
|
|
175
|
+
)
|
|
176
|
+
for name in names
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_METHOD_NAMES = frozenset(name for _, name in _METHODS)
|
|
180
|
+
|
|
181
|
+
#: Every node the walker will evaluate. `Constant`, `Name`, `Attribute` and
|
|
182
|
+
#: `Call` are listed here for completeness but are decided by their own branches
|
|
183
|
+
#: in `_check` — they need context this set cannot carry.
|
|
184
|
+
_ALLOWED_NODES = frozenset({
|
|
185
|
+
ast.Expression, ast.Load, ast.Subscript, ast.Slice, ast.IfExp,
|
|
186
|
+
ast.Tuple, ast.List, ast.Set, ast.Dict,
|
|
187
|
+
ast.Constant, ast.Name, ast.Attribute, ast.Call,
|
|
188
|
+
ast.BoolOp, ast.And, ast.Or,
|
|
189
|
+
ast.UnaryOp, ast.Not, ast.USub, ast.UAdd,
|
|
190
|
+
ast.BinOp, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,
|
|
191
|
+
ast.Compare, ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
|
|
192
|
+
ast.In, ast.NotIn, ast.Is, ast.IsNot,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
_NODE_NAMES = {
|
|
196
|
+
ast.Lambda: 'a lambda', ast.ListComp: 'a list comprehension',
|
|
197
|
+
ast.SetComp: 'a set comprehension', ast.DictComp: 'a dict comprehension',
|
|
198
|
+
ast.GeneratorExp: 'a generator expression', ast.comprehension: 'a comprehension',
|
|
199
|
+
ast.JoinedStr: 'an f-string', ast.FormattedValue: 'an f-string',
|
|
200
|
+
ast.NamedExpr: 'a walrus assignment', ast.Starred: 'argument unpacking',
|
|
201
|
+
ast.Await: 'await', ast.Yield: 'yield', ast.YieldFrom: 'yield from',
|
|
202
|
+
ast.LShift: '<<', ast.RShift: '>>', ast.BitOr: '|', ast.BitAnd: '&',
|
|
203
|
+
ast.BitXor: '^', ast.Invert: '~', ast.MatMult: '@',
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
_LOOP_HINT = ' — filter in a transform step and loop over what it writes'
|
|
207
|
+
_HINTS = {
|
|
208
|
+
ast.JoinedStr: ' — build the text with str(…) and +',
|
|
209
|
+
ast.FormattedValue: ' — build the text with str(…) and +',
|
|
210
|
+
ast.ListComp: _LOOP_HINT, ast.SetComp: _LOOP_HINT,
|
|
211
|
+
ast.DictComp: _LOOP_HINT, ast.GeneratorExp: _LOOP_HINT,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#: Naveen's one live expression is 5 nodes; the longest shipped template is ~20.
|
|
215
|
+
#: The depth cap also keeps the recursive walker away from `RecursionError`.
|
|
216
|
+
_MAX_NODES = 200
|
|
217
|
+
_MAX_DEPTH = 25
|
|
218
|
+
|
|
219
|
+
#: `10 ** 10**10` and `'x' * 10**9` are each one token of memory exhaustion, and
|
|
220
|
+
#: `'%1000000000d' % 1` allocates a gigabyte — the three ways left to spend real
|
|
221
|
+
#: time once iteration is gone. String `%` also re-enters `__getitem__` on its
|
|
222
|
+
#: right operand, so it is numbers only.
|
|
223
|
+
_MAX_POW_EXP = 64
|
|
224
|
+
_MAX_SEQ_REPEAT = 100_000
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _describe(kind: type) -> str:
|
|
228
|
+
return _NODE_NAMES.get(kind, kind.__name__)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── The static pass: everything that can be decided without a value ──────────
|
|
232
|
+
|
|
233
|
+
def _check(node: ast.AST, depth: int, count: list[int], *, func_pos: bool = False) -> None:
|
|
234
|
+
count[0] += 1
|
|
235
|
+
if count[0] > _MAX_NODES:
|
|
236
|
+
raise ExpressionRefused(f'the expression has more than {_MAX_NODES} parts')
|
|
237
|
+
if depth > _MAX_DEPTH:
|
|
238
|
+
raise ExpressionRefused(f'the expression is nested more than {_MAX_DEPTH} deep')
|
|
239
|
+
|
|
240
|
+
kind = type(node)
|
|
241
|
+
|
|
242
|
+
if kind is ast.Name:
|
|
243
|
+
if func_pos:
|
|
244
|
+
if node.id not in _FUNCS:
|
|
245
|
+
raise ExpressionRefused(
|
|
246
|
+
f'{node.id}(…) is not one of the functions available here '
|
|
247
|
+
f'({", ".join(sorted(_FUNCS))})')
|
|
248
|
+
elif node.id != _STATE:
|
|
249
|
+
extra = f'; {node.id}(…) is available as a function' if node.id in _FUNCS else ''
|
|
250
|
+
raise ExpressionRefused(
|
|
251
|
+
f"'{node.id}' is not a name an expression can read — only `state` is{extra}")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
if kind is ast.Attribute:
|
|
255
|
+
if node.attr.startswith('_'):
|
|
256
|
+
raise ExpressionRefused(
|
|
257
|
+
f"'.{node.attr}' is not readable — an attribute beginning with an "
|
|
258
|
+
f'underscore is never available to an expression')
|
|
259
|
+
if func_pos and node.attr not in _METHOD_NAMES:
|
|
260
|
+
raise ExpressionRefused(f'.{node.attr}(…) is not one of the methods available here')
|
|
261
|
+
_check(node.value, depth + 1, count)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
if kind is ast.Call:
|
|
265
|
+
if node.keywords:
|
|
266
|
+
raise ExpressionRefused('keyword arguments are not allowed')
|
|
267
|
+
if not isinstance(node.func, (ast.Name, ast.Attribute)):
|
|
268
|
+
raise ExpressionRefused('only a named function or a method may be called')
|
|
269
|
+
_check(node.func, depth + 1, count, func_pos=True)
|
|
270
|
+
for arg in node.args:
|
|
271
|
+
_check(arg, depth + 1, count)
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
if kind is ast.Constant:
|
|
275
|
+
if node.value is not None and not isinstance(node.value, (str, int, float, bool)):
|
|
276
|
+
raise ExpressionRefused(f'{type(node.value).__name__} literals are not allowed')
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
if kind is ast.Dict and any(key is None for key in node.keys):
|
|
280
|
+
# `{**other}` parses as a key of None, which `iter_child_nodes` skips —
|
|
281
|
+
# so it has to be caught here or it reaches the walker unchecked.
|
|
282
|
+
raise ExpressionRefused('dictionary unpacking is not allowed')
|
|
283
|
+
|
|
284
|
+
if kind not in _ALLOWED_NODES:
|
|
285
|
+
raise ExpressionRefused(
|
|
286
|
+
f'{_describe(kind)} is not allowed in an expression{_HINTS.get(kind, "")}')
|
|
287
|
+
|
|
288
|
+
for child in ast.iter_child_nodes(node):
|
|
289
|
+
_check(child, depth + 1, count)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@functools.lru_cache(maxsize=256)
|
|
293
|
+
def _compiled(expr: str) -> tuple[ast.Expression | None, str | None, bool]:
|
|
294
|
+
"""`(tree, error, refused)` — the tree is None whenever an error is set.
|
|
295
|
+
|
|
296
|
+
Cached because `loop_while_condition` is re-evaluated on every iteration up
|
|
297
|
+
to `loop_max_iterations`, so the steady-state cost of an expression has to
|
|
298
|
+
be a dict lookup rather than a parse.
|
|
299
|
+
"""
|
|
300
|
+
try:
|
|
301
|
+
tree = ast.parse(expr, mode='eval')
|
|
302
|
+
except SyntaxError as exc:
|
|
303
|
+
# A typo is not a refusal. It reaches the caller as the same runtime
|
|
304
|
+
# error string `eval()` used to produce.
|
|
305
|
+
return None, str(exc), False
|
|
306
|
+
try:
|
|
307
|
+
_check(tree, 0, [0])
|
|
308
|
+
except ExpressionRefused as exc:
|
|
309
|
+
return None, str(exc), True
|
|
310
|
+
return tree, None, False
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ── The walk: everything that needs a value ──────────────────────────────────
|
|
314
|
+
|
|
315
|
+
def _guard_pow(base: Any, exp: Any) -> None:
|
|
316
|
+
if isinstance(base, int) and isinstance(exp, int) and exp > _MAX_POW_EXP:
|
|
317
|
+
raise ValueError(f'** is limited to an exponent of {_MAX_POW_EXP} here')
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _guard_repeat(left: Any, right: Any) -> None:
|
|
321
|
+
for seq, n in ((left, right), (right, left)):
|
|
322
|
+
if isinstance(seq, (str, list, tuple)) and isinstance(n, int):
|
|
323
|
+
if len(seq) * n > _MAX_SEQ_REPEAT:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f'repeating a sequence past {_MAX_SEQ_REPEAT} items is not allowed here')
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
_BINOPS = {
|
|
329
|
+
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
|
330
|
+
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
|
331
|
+
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_CMPOPS = {
|
|
335
|
+
ast.Eq: operator.eq, ast.NotEq: operator.ne,
|
|
336
|
+
ast.Lt: operator.lt, ast.LtE: operator.le,
|
|
337
|
+
ast.Gt: operator.gt, ast.GtE: operator.ge,
|
|
338
|
+
ast.Is: operator.is_, ast.IsNot: operator.is_not,
|
|
339
|
+
ast.In: lambda a, b: a in b,
|
|
340
|
+
ast.NotIn: lambda a, b: a not in b,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _Walk:
|
|
345
|
+
__slots__ = ('state',)
|
|
346
|
+
|
|
347
|
+
def __init__(self, state: DotNavigableState):
|
|
348
|
+
self.state = state
|
|
349
|
+
|
|
350
|
+
def run(self, tree: ast.Expression) -> Any:
|
|
351
|
+
return self._eval(tree.body)
|
|
352
|
+
|
|
353
|
+
def _eval(self, node: ast.AST) -> Any:
|
|
354
|
+
handler = _DISPATCH.get(type(node))
|
|
355
|
+
if handler is None:
|
|
356
|
+
# `_check` has already refused anything without a handler. This is
|
|
357
|
+
# the backstop: a node type added to `_ALLOWED_NODES` without an
|
|
358
|
+
# evaluator fails loudly instead of silently doing something else.
|
|
359
|
+
raise ExpressionRefused(f'{_describe(type(node))} cannot be evaluated')
|
|
360
|
+
return handler(self, node)
|
|
361
|
+
|
|
362
|
+
def _constant(self, node):
|
|
363
|
+
return node.value
|
|
364
|
+
|
|
365
|
+
def _name(self, node):
|
|
366
|
+
if node.id == _STATE:
|
|
367
|
+
return self.state
|
|
368
|
+
raise ExpressionRefused(f"'{node.id}' is not a name an expression can read")
|
|
369
|
+
|
|
370
|
+
def _attribute(self, node):
|
|
371
|
+
receiver = self._eval(node.value)
|
|
372
|
+
if isinstance(receiver, DotNavigableState):
|
|
373
|
+
# Delegated on purpose: missing-key-is-None and the nested rewrap are
|
|
374
|
+
# the wrapper's, not a second copy of them that can drift.
|
|
375
|
+
return getattr(receiver, node.attr)
|
|
376
|
+
raise AttributeError(
|
|
377
|
+
f"'{type(receiver).__name__}' object has no attribute '{node.attr}'")
|
|
378
|
+
|
|
379
|
+
def _subscript(self, node):
|
|
380
|
+
return self._eval(node.value)[self._eval(node.slice)]
|
|
381
|
+
|
|
382
|
+
def _slice(self, node):
|
|
383
|
+
return slice(
|
|
384
|
+
self._eval(node.lower) if node.lower is not None else None,
|
|
385
|
+
self._eval(node.upper) if node.upper is not None else None,
|
|
386
|
+
self._eval(node.step) if node.step is not None else None,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _call(self, node):
|
|
390
|
+
func = node.func
|
|
391
|
+
if type(func) is ast.Name:
|
|
392
|
+
fn = _FUNCS.get(func.id)
|
|
393
|
+
if fn is None:
|
|
394
|
+
raise ExpressionRefused(f'{func.id}(…) is not available here')
|
|
395
|
+
return fn(*[self._eval(a) for a in node.args])
|
|
396
|
+
receiver = self._eval(func.value)
|
|
397
|
+
fn = _METHODS.get((type(receiver), func.attr))
|
|
398
|
+
if fn is None:
|
|
399
|
+
raise TypeError(
|
|
400
|
+
f"'{type(receiver).__name__}' object has no method "
|
|
401
|
+
f"'{func.attr}' available to an expression")
|
|
402
|
+
return fn(receiver, *[self._eval(a) for a in node.args])
|
|
403
|
+
|
|
404
|
+
def _boolop(self, node):
|
|
405
|
+
if type(node.op) is ast.And:
|
|
406
|
+
value: Any = True
|
|
407
|
+
for operand in node.values:
|
|
408
|
+
value = self._eval(operand)
|
|
409
|
+
if not value:
|
|
410
|
+
return value
|
|
411
|
+
return value
|
|
412
|
+
value = False
|
|
413
|
+
for operand in node.values:
|
|
414
|
+
value = self._eval(operand)
|
|
415
|
+
if value:
|
|
416
|
+
return value
|
|
417
|
+
return value
|
|
418
|
+
|
|
419
|
+
def _unaryop(self, node):
|
|
420
|
+
value = self._eval(node.operand)
|
|
421
|
+
kind = type(node.op)
|
|
422
|
+
if kind is ast.Not:
|
|
423
|
+
return not value
|
|
424
|
+
if kind is ast.USub:
|
|
425
|
+
return -value
|
|
426
|
+
return +value
|
|
427
|
+
|
|
428
|
+
def _binop(self, node):
|
|
429
|
+
left = self._eval(node.left)
|
|
430
|
+
right = self._eval(node.right)
|
|
431
|
+
kind = type(node.op)
|
|
432
|
+
if kind is ast.Pow:
|
|
433
|
+
_guard_pow(left, right)
|
|
434
|
+
elif kind is ast.Mult:
|
|
435
|
+
_guard_repeat(left, right)
|
|
436
|
+
elif kind is ast.Mod and not (
|
|
437
|
+
isinstance(left, (int, float)) and isinstance(right, (int, float))
|
|
438
|
+
):
|
|
439
|
+
raise TypeError('% is only available between numbers')
|
|
440
|
+
return _BINOPS[kind](left, right)
|
|
441
|
+
|
|
442
|
+
def _compare(self, node):
|
|
443
|
+
left = self._eval(node.left)
|
|
444
|
+
for op, comparator in zip(node.ops, node.comparators):
|
|
445
|
+
right = self._eval(comparator)
|
|
446
|
+
if not _CMPOPS[type(op)](left, right):
|
|
447
|
+
return False
|
|
448
|
+
left = right
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
def _ifexp(self, node):
|
|
452
|
+
return self._eval(node.body) if self._eval(node.test) else self._eval(node.orelse)
|
|
453
|
+
|
|
454
|
+
def _tuple(self, node):
|
|
455
|
+
return tuple(_raw(self._eval(e)) for e in node.elts)
|
|
456
|
+
|
|
457
|
+
def _list(self, node):
|
|
458
|
+
return [_raw(self._eval(e)) for e in node.elts]
|
|
459
|
+
|
|
460
|
+
def _set(self, node):
|
|
461
|
+
return {_raw(self._eval(e)) for e in node.elts}
|
|
462
|
+
|
|
463
|
+
def _dict(self, node):
|
|
464
|
+
return {
|
|
465
|
+
self._eval(k): _raw(self._eval(v))
|
|
466
|
+
for k, v in zip(node.keys, node.values)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
_DISPATCH = {
|
|
471
|
+
ast.Constant: _Walk._constant, ast.Name: _Walk._name,
|
|
472
|
+
ast.Attribute: _Walk._attribute, ast.Subscript: _Walk._subscript,
|
|
473
|
+
ast.Slice: _Walk._slice, ast.Call: _Walk._call,
|
|
474
|
+
ast.BoolOp: _Walk._boolop, ast.UnaryOp: _Walk._unaryop,
|
|
475
|
+
ast.BinOp: _Walk._binop, ast.Compare: _Walk._compare,
|
|
476
|
+
ast.IfExp: _Walk._ifexp, ast.Tuple: _Walk._tuple,
|
|
477
|
+
ast.List: _Walk._list, ast.Set: _Walk._set, ast.Dict: _Walk._dict,
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ── The three questions callers ask ──────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
def check_expression(expr: str | None) -> str | None:
|
|
484
|
+
"""`None` if the expression can be evaluated, else one sentence saying why not.
|
|
485
|
+
|
|
486
|
+
Pure syntax — it needs no state, which is what makes it usable while someone
|
|
487
|
+
is still writing the step. Reports typos as well as refusals; an empty
|
|
488
|
+
expression is not this function's business, because every caller already
|
|
489
|
+
reports a missing one in its own words.
|
|
490
|
+
"""
|
|
491
|
+
if not expr or not expr.strip():
|
|
492
|
+
return None
|
|
493
|
+
return _compiled(expr)[1]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def unsafe_reason(expr: str | None) -> str | None:
|
|
497
|
+
"""`None` unless the expression is *refused* — the hard gate.
|
|
498
|
+
|
|
499
|
+
A merely invalid expression is not refused here: refusing a typo at the
|
|
500
|
+
point of writing would stop a builder tool call for something the engine
|
|
501
|
+
already survives. `check_expression` is the advisory that reports those.
|
|
502
|
+
"""
|
|
503
|
+
if not expr or not expr.strip():
|
|
504
|
+
return None
|
|
505
|
+
_, error, refused = _compiled(expr)
|
|
506
|
+
return error if refused else None
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def evaluate_expression(expr: str | None, state: dict, default: Any = None) -> ExprResult:
|
|
510
|
+
"""Evaluate `expr` against shared state. Never raises."""
|
|
511
|
+
if not expr or not expr.strip():
|
|
512
|
+
return ExprResult(default, 'the expression is empty')
|
|
513
|
+
tree, error, refused = _compiled(expr)
|
|
514
|
+
if tree is None:
|
|
515
|
+
return ExprResult(default, error, refused)
|
|
516
|
+
try:
|
|
517
|
+
value = _Walk(DotNavigableState(state)).run(tree)
|
|
518
|
+
except ExpressionRefused as exc:
|
|
519
|
+
return ExprResult(default, str(exc), True)
|
|
520
|
+
except Exception as exc:
|
|
521
|
+
return ExprResult(default, str(exc), False)
|
|
522
|
+
return ExprResult(_raw(value), None, False)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Where an agent's file tools may read — one definition (S5).
|
|
2
|
+
|
|
3
|
+
The Filesystem MCP server has always been started on a list of roots: the
|
|
4
|
+
linked repositories, the vault, and the directories named in Settings →
|
|
5
|
+
Allowed Directories. The engine's own file tools — `file_reader`'s
|
|
6
|
+
`read_file_by_lines` / `grep_file`, and `code_search`'s `grep`, `glob` and
|
|
7
|
+
`read_file_by_lines` — opened whatever path they were handed. An agent steered
|
|
8
|
+
by what it had just read could read `.env`, secrets in the data directory, or
|
|
9
|
+
`~/.aws/credentials`.
|
|
10
|
+
|
|
11
|
+
So there is one list now, and every file tool checks against it: the same list
|
|
12
|
+
the Filesystem server is given, built here and nowhere else.
|
|
13
|
+
|
|
14
|
+
`within()` is the containment test. It compares real paths (symlinks resolved,
|
|
15
|
+
`..` collapsed) and requires the separator after the base, so `vault-old` is
|
|
16
|
+
not inside `vault` (S8).
|
|
17
|
+
|
|
18
|
+
(1.9.x port: repositories come from `DATA_DIR/repos.json` and the vault is
|
|
19
|
+
`DATA_DIR/vault`, as everywhere else in this line.)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
REFUSAL_HINT = (
|
|
29
|
+
"Agents may read only inside linked repositories, the vault, and the folders "
|
|
30
|
+
"listed in Settings → Allowed Directories."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def repo_paths() -> list[str]:
|
|
35
|
+
"""The linked repositories that exist on this machine, in file order."""
|
|
36
|
+
from core.config import DATA_DIR
|
|
37
|
+
|
|
38
|
+
repos_file = Path(DATA_DIR) / "repos.json"
|
|
39
|
+
if not repos_file.exists():
|
|
40
|
+
return []
|
|
41
|
+
try:
|
|
42
|
+
repos = json.loads(repos_file.read_text(encoding="utf-8"))
|
|
43
|
+
return [r["path"] for r in repos if r.get("path") and os.path.isdir(r["path"])]
|
|
44
|
+
except Exception as e: # noqa: BLE001
|
|
45
|
+
print(f"Warning: Could not load repo paths: {e}")
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def allowed_roots() -> list[str]:
|
|
50
|
+
"""Repositories, then the vault, then Allowed Directories not already covered.
|
|
51
|
+
|
|
52
|
+
The order and the de-duplication are the Filesystem server's argument list,
|
|
53
|
+
exactly — `core/server.py` starts it from this.
|
|
54
|
+
"""
|
|
55
|
+
from core.config import DATA_DIR, load_settings
|
|
56
|
+
|
|
57
|
+
roots = repo_paths() + [str(Path(DATA_DIR) / "vault")]
|
|
58
|
+
seen = {os.path.realpath(p) for p in roots}
|
|
59
|
+
for d in load_settings().get("bash_allowed_dirs", []) or []:
|
|
60
|
+
if not d or not os.path.isdir(d):
|
|
61
|
+
continue
|
|
62
|
+
real = os.path.realpath(d)
|
|
63
|
+
if real not in seen:
|
|
64
|
+
roots.append(d)
|
|
65
|
+
seen.add(real)
|
|
66
|
+
return roots
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def within(path: str | os.PathLike, roots: list[str] | list[Path]) -> bool:
|
|
70
|
+
"""True if `path`, fully resolved, is one of `roots` or inside one."""
|
|
71
|
+
resolved = os.path.realpath(path)
|
|
72
|
+
for root in roots:
|
|
73
|
+
base = os.path.realpath(root)
|
|
74
|
+
if resolved == base or resolved.startswith(base.rstrip(os.sep) + os.sep):
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def refusal(path: str) -> str:
|
|
80
|
+
return f"Access denied: '{path}' is outside the folders agents may read. {REFUSAL_HINT}"
|