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.
Files changed (140) hide show
  1. package/README.md +4 -0
  2. package/backend/core/config.py +10 -0
  3. package/backend/core/egress_guard.py +202 -0
  4. package/backend/core/expressions.py +522 -0
  5. package/backend/core/file_roots.py +80 -0
  6. package/backend/core/internal_auth.py +129 -20
  7. package/backend/core/mcp_client.py +62 -1
  8. package/backend/core/models.py +20 -2
  9. package/backend/core/orchestration/steps.py +38 -75
  10. package/backend/core/react_engine.py +8 -0
  11. package/backend/core/routes/settings.py +5 -1
  12. package/backend/core/routes/tools.py +28 -4
  13. package/backend/core/safe_fetch.py +106 -0
  14. package/backend/core/scale/worker_server_module.py +22 -1
  15. package/backend/core/server.py +42 -25
  16. package/backend/tools/code_search.py +23 -21
  17. package/backend/tools/file_reader.py +12 -2
  18. package/backend/tools/pdf_parser.py +5 -5
  19. package/backend/tools/sandbox.py +6 -2
  20. package/backend/tools/web_scraper.py +80 -0
  21. package/backend/tools/xlsx_parser.py +5 -5
  22. package/frontend-build/.next/BUILD_ID +1 -1
  23. package/frontend-build/.next/build-manifest.json +3 -3
  24. package/frontend-build/.next/prerender-manifest.json +3 -3
  25. package/frontend-build/.next/server/app/_global-error.html +1 -1
  26. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  27. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  28. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  29. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  33. package/frontend-build/.next/server/app/_not-found.html +1 -1
  34. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  35. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  36. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  37. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  38. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  39. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  40. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  41. package/frontend-build/.next/server/app/api/agent-types/route.js +3 -2
  42. package/frontend-build/.next/server/app/api/agent-types/route.js.nft.json +1 -1
  43. package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js +3 -2
  44. package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js.nft.json +1 -1
  45. package/frontend-build/.next/server/app/api/auth/login/route.js +3 -2
  46. package/frontend-build/.next/server/app/api/auth/login/route.js.nft.json +1 -1
  47. package/frontend-build/.next/server/app/api/auth/logout/route.js +2 -1
  48. package/frontend-build/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  49. package/frontend-build/.next/server/app/api/auth/status/route.js +3 -2
  50. package/frontend-build/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  51. package/frontend-build/.next/server/app/api/builder/chat/route.js +3 -2
  52. package/frontend-build/.next/server/app/api/builder/chat/route.js.nft.json +1 -1
  53. package/frontend-build/.next/server/app/api/builder/resume/route.js +3 -2
  54. package/frontend-build/.next/server/app/api/builder/resume/route.js.nft.json +1 -1
  55. package/frontend-build/.next/server/app/api/chat/route.js +3 -2
  56. package/frontend-build/.next/server/app/api/chat/route.js.nft.json +1 -1
  57. package/frontend-build/.next/server/app/api/chat/stream/route.js +3 -2
  58. package/frontend-build/.next/server/app/api/chat/stream/route.js.nft.json +1 -1
  59. package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js +3 -2
  60. package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js.nft.json +1 -1
  61. package/frontend-build/.next/server/app/api/logs/[type]/route.js +3 -2
  62. package/frontend-build/.next/server/app/api/logs/[type]/route.js.nft.json +1 -1
  63. package/frontend-build/.next/server/app/api/models/route.js +3 -2
  64. package/frontend-build/.next/server/app/api/models/route.js.nft.json +1 -1
  65. package/frontend-build/.next/server/app/api/notifications/stream/route.js +3 -2
  66. package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -1
  67. package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js +3 -2
  68. package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js.nft.json +1 -1
  69. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +3 -2
  70. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -1
  71. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js +3 -2
  72. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js.nft.json +1 -1
  73. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js +3 -2
  74. package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js.nft.json +1 -1
  75. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js +3 -2
  76. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js.nft.json +1 -1
  77. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js +3 -2
  78. package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js.nft.json +1 -1
  79. package/frontend-build/.next/server/app/api/schedules/route.js +3 -2
  80. package/frontend-build/.next/server/app/api/schedules/route.js.nft.json +1 -1
  81. package/frontend-build/.next/server/app/icon.png/route.js +2 -1
  82. package/frontend-build/.next/server/app/icon.png/route.js.nft.json +1 -1
  83. package/frontend-build/.next/server/app/index.html +1 -1
  84. package/frontend-build/.next/server/app/index.rsc +2 -2
  85. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  86. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  87. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  88. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  89. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  90. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  91. package/frontend-build/.next/server/app/login.html +1 -1
  92. package/frontend-build/.next/server/app/login.rsc +2 -2
  93. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  94. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  95. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  96. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  97. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  98. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  99. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  100. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  101. package/frontend-build/.next/server/chunks/[root-of-the-server]__0.-gycy._.js +3 -0
  102. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0~bybkp._.js → [root-of-the-server]__003vgom._.js} +2 -2
  103. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t4a96i._.js → [root-of-the-server]__03ek9xq._.js} +2 -2
  104. package/frontend-build/.next/server/chunks/{[root-of-the-server]__07vf6z1._.js → [root-of-the-server]__03x0ii~._.js} +2 -2
  105. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0wqyl-j._.js → [root-of-the-server]__03zuar1._.js} +2 -2
  106. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0zl78ks._.js → [root-of-the-server]__04ks5f_._.js} +2 -2
  107. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t.cymd._.js → [root-of-the-server]__0_fafcz._.js} +2 -2
  108. package/frontend-build/.next/server/chunks/[root-of-the-server]__0_~_1-l._.js +10 -0
  109. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0fa0k5_._.js → [root-of-the-server]__0dbuutb._.js} +2 -2
  110. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0hnd6qu._.js → [root-of-the-server]__0hlm8c7._.js} +2 -2
  111. package/frontend-build/.next/server/chunks/[root-of-the-server]__0is-hlj._.js +3 -0
  112. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0n-ayat._.js → [root-of-the-server]__0i~uoki._.js} +2 -2
  113. package/frontend-build/.next/server/chunks/[root-of-the-server]__0js9sik._.js +3 -0
  114. package/frontend-build/.next/server/chunks/[root-of-the-server]__0ncun1_._.js +1 -1
  115. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0i0g0o9._.js → [root-of-the-server]__0oe4zjm._.js} +2 -2
  116. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0av_50r._.js → [root-of-the-server]__0v4clie._.js} +2 -2
  117. package/frontend-build/.next/server/chunks/[root-of-the-server]__0w02yhg._.js +3 -0
  118. package/frontend-build/.next/server/chunks/[root-of-the-server]__0wbbjuq._.js +3 -0
  119. package/frontend-build/.next/server/chunks/{[root-of-the-server]__0-0m2_n._.js → [root-of-the-server]__0wgri4w._.js} +2 -2
  120. package/frontend-build/.next/server/chunks/[root-of-the-server]__0xtvg9h._.js +3 -0
  121. package/frontend-build/.next/server/chunks/{[root-of-the-server]__01abhvg._.js → [root-of-the-server]__0yuz2ug._.js} +2 -2
  122. package/frontend-build/.next/server/chunks/node_modules_next_124cnn1._.js +3 -0
  123. package/frontend-build/.next/server/chunks/node_modules_next_dist_12gp6q5._.js +6 -0
  124. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  125. package/frontend-build/.next/server/pages/404.html +1 -1
  126. package/frontend-build/.next/server/pages/500.html +1 -1
  127. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  128. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  129. package/frontend-build/.next/static/chunks/{0a-yeaiijslpf.css → 13upr_rmz7sy8.css} +5 -5
  130. package/package.json +1 -1
  131. package/frontend-build/.next/server/chunks/[root-of-the-server]__08ei_wz._.js +0 -3
  132. package/frontend-build/.next/server/chunks/[root-of-the-server]__0_qd3xa._.js +0 -3
  133. package/frontend-build/.next/server/chunks/[root-of-the-server]__0aw6-s5._.js +0 -3
  134. package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +0 -3
  135. package/frontend-build/.next/server/chunks/[root-of-the-server]__0j8-xkl._.js +0 -13
  136. package/frontend-build/.next/server/chunks/[root-of-the-server]__0uulfit._.js +0 -3
  137. package/frontend-build/.next/server/chunks/[root-of-the-server]__10fof-m._.js +0 -3
  138. /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_buildManifest.js +0 -0
  139. /package/frontend-build/.next/static/{08oDqCbK0BRht0JB9AgpU → YrERDQ96GLlwEG7rNI1lz}/_clientMiddlewareManifest.js +0 -0
  140. /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}"