linkgravity 1.6.0 → 1.6.1

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/bin/cli.js CHANGED
@@ -49,6 +49,9 @@ function runPm2(args, silent = true) {
49
49
  ...process.env,
50
50
  // pm2 gives Python a pipe not a TTY, so it block-buffers stdout and can sit on log lines indefinitely - force line buffering.
51
51
  PYTHONUNBUFFERED: '1',
52
+ // pm2 merges --update-env rather than replacing, so a LOG_LEVEL from an earlier run
53
+ // survives unless a value is passed every time.
54
+ LOG_LEVEL: process.env.LOG_LEVEL || 'INFO',
52
55
  // Version managers (fnm, nvm) put node on PATH from a shell hook the daemon never runs,
53
56
  // so the bot's own `node` lookup for voice-service would fail without this.
54
57
  PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH || ''}`,
@@ -106,7 +109,7 @@ function runSudoStepThen(sudoCommand, successMessage) {
106
109
  }
107
110
  }
108
111
 
109
- // Matches a leading timestamp from either loguru or aiohttp's access-log format; only strips the first bracket group so aiohttp's second "[INFO ]" bracket is left alone.
112
+ // Matches a leading timestamp from either loguru or aiohttp's access-log format.
110
113
  const TIMESTAMP_PREFIX = /^\[?\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}\]?\s*/;
111
114
  // loguru's colorize=True puts an ANSI code before the timestamp digits, breaking the '^' anchor above.
112
115
  // eslint-disable-next-line no-control-regex
@@ -390,7 +393,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
390
393
  }
391
394
 
392
395
  info('Starting LinkGravity daemon...');
393
- runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
396
+ runPm2([
397
+ 'start',
398
+ LGY_SCRIPT_PATH,
399
+ '--interpreter',
400
+ pythonExe,
401
+ '--name',
402
+ LGY_PM2_NAME,
403
+ '--update-env',
404
+ ]);
394
405
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
395
406
  } else if (cmd === 'stop') {
396
407
  info('Stopping LinkGravity daemon...');
@@ -616,7 +627,15 @@ if (cmd === 'version' || cmd === '-v' || cmd === '--version') {
616
627
 
617
628
  if (!procBeforeUpdate) {
618
629
  info("Daemon wasn't running - starting it fresh...");
619
- runPm2(['start', LGY_SCRIPT_PATH, '--interpreter', pythonExe, '--name', LGY_PM2_NAME]);
630
+ runPm2([
631
+ 'start',
632
+ LGY_SCRIPT_PATH,
633
+ '--interpreter',
634
+ pythonExe,
635
+ '--name',
636
+ LGY_PM2_NAME,
637
+ '--update-env',
638
+ ]);
620
639
  verifyStartup().then((ok) => process.exit(ok ? 0 : 1));
621
640
  } else if (wasOnline) {
622
641
  info('Restarting daemon to apply the update...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linkgravity",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Discord/Telegram bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
5
  "scripts": {
6
6
  "start": "node npm-scripts/run-dev.js",
@@ -7,6 +7,7 @@ import uuid
7
7
  from aiohttp import web
8
8
 
9
9
  from api.server import is_tool_allowed
10
+ from approval.protected_paths import protected_reason
10
11
  from config import APPROVAL_TIMEOUT_SEC, MAX_EMBED_LEN, logger, session_manager
11
12
  from messengers.base import ScopeOption
12
13
  from messengers.registry import get_adapter_for_platform, get_adapter_for_thread
@@ -87,6 +88,12 @@ async def handle_approve_request(request):
87
88
  # DEBUG-only (see logger.py's LOG_LEVEL). Silent by default.
88
89
  logger.debug(f"[APPROVE HOOK] tool_name={tool_name!r} conv_id={conv_id!r} tool_input={tool_input!r}")
89
90
 
91
+ # Ahead of the auto-allow lookup: this must not be overridable by a persistent grant.
92
+ blocked = protected_reason(tool_input)
93
+ if blocked:
94
+ logger.warning(f"Blocked {tool_name} touching protected config: {tool_input!r}")
95
+ return web.json_response({"decision": "deny", "reason": blocked})
96
+
90
97
  target_thread = None
91
98
  target_thread_id = None
92
99
  for thread_id_str, sess in session_manager.get_all_sessions().items():
@@ -0,0 +1,42 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from config import WORKSPACE_DIR
6
+
7
+ AGENT_SUBDIR = "workspace"
8
+
9
+ _DIR_MARKER = f"{WORKSPACE_DIR.parent.name}/{WORKSPACE_DIR.name}"
10
+
11
+ # Shell commands arrive as one opaque string, so they are matched textually rather than resolved.
12
+ CONFIG_DIR_RE = re.compile(re.escape(_DIR_MARKER) + rf"(?!/{re.escape(AGENT_SUBDIR)}\b)")
13
+
14
+ NAME_RE = re.compile(r"(?<![\w.-])(?:lgy\.json|persistent_tools\.json|approve_token)(?![\w.])")
15
+
16
+ REASON = "LinkGravity's own configuration is off limits - it holds the bot tokens."
17
+
18
+
19
+ def _is_inside_config(value: str) -> bool:
20
+ try:
21
+ resolved = Path(os.path.expandvars(os.path.expanduser(value))).resolve()
22
+ except (OSError, ValueError):
23
+ return False
24
+ root = WORKSPACE_DIR.resolve()
25
+ return resolved.is_relative_to(root) and not resolved.is_relative_to(root / AGENT_SUBDIR)
26
+
27
+
28
+ def protected_reason(tool_input) -> str | None:
29
+ if isinstance(tool_input, list):
30
+ return next((r for r in map(protected_reason, tool_input) if r), None)
31
+ if not isinstance(tool_input, dict):
32
+ return None
33
+
34
+ for value in tool_input.values():
35
+ if isinstance(value, (dict, list)):
36
+ nested = protected_reason(value)
37
+ if nested:
38
+ return nested
39
+ elif isinstance(value, str) and value:
40
+ if NAME_RE.search(value) or CONFIG_DIR_RE.search(value) or _is_inside_config(value):
41
+ return REASON
42
+ return None
@@ -6,6 +6,17 @@ from sys import stdout
6
6
  from loguru import logger
7
7
 
8
8
 
9
+ class _InterceptHandler(logging.Handler):
10
+ def emit(self, record: logging.LogRecord) -> None:
11
+ try:
12
+ level = logger.level(record.levelname).name
13
+ except ValueError:
14
+ level = record.levelno
15
+ # Without patching, {name} resolves to this file's frame instead of the library that logged.
16
+ patched = logger.patch(lambda r, name=record.name: r.update(name=name))
17
+ patched.opt(exception=record.exc_info).log(level, record.getMessage())
18
+
19
+
9
20
  def init_logger(workspace_dir: Path):
10
21
  logging.getLogger("discord").setLevel(logging.WARNING)
11
22
  # httpx is what python-telegram-bot uses under the hood for every getUpdates
@@ -22,6 +33,9 @@ def init_logger(workspace_dir: Path):
22
33
  # Defaults to INFO - set LOG_LEVEL=DEBUG then `lgy restart` for
23
34
  # verbose detail (e.g. agy_runner.py's raw agy stdout capture).
24
35
  level = os.environ.get("LOG_LEVEL", "INFO").upper()
36
+ # force=True drops handlers third-party libraries install for themselves, which otherwise
37
+ # print in their own format alongside loguru's.
38
+ logging.basicConfig(handlers=[_InterceptHandler()], level=getattr(logging, level, logging.INFO), force=True)
25
39
  logger.add(stdout, level=level, format=log_format, colorize=True)
26
40
  logger.add(
27
41
  LOG_DIR / "bot.log",
@@ -281,8 +281,6 @@ async def run_discord(stop_event: asyncio.Event) -> None:
281
281
  logger.critical("Missing DISCORD_TOKEN - run `lgy setup`")
282
282
  return
283
283
 
284
- discord.utils.setup_logging()
285
-
286
284
  async with bot:
287
285
  from cogs.voice_cog import VoiceCog
288
286
  from config import bot_settings, save_bot_settings
@@ -234,6 +234,8 @@ class SlackAdapter(MessengerAdapter):
234
234
  user_id = (body.get("user") or {}).get("id")
235
235
  if allowed(user_id, "slack"):
236
236
  return True
237
+ logger.warning(f"Rejected Slack interaction from unauthorized user {user_id}")
238
+ # Modal submissions carry no channel, so there is nowhere to post the notice.
237
239
  channel = (body.get("channel") or {}).get("id")
238
240
  if channel and user_id:
239
241
  try:
@@ -245,6 +247,8 @@ class SlackAdapter(MessengerAdapter):
245
247
  return False
246
248
 
247
249
  async def handle_view_submission(self, body: dict) -> None:
250
+ if not await self._reject_unauthorized(body):
251
+ return
248
252
  callback_id = (body.get("view") or {}).get("callback_id")
249
253
  handler = self._view_callbacks.pop(callback_id, None)
250
254
  if handler is None: