argus-hermes-bridge 0.0.0-stage → 0.1.0
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/HermesSetupPrompt.txt +16 -0
- package/README.md +104 -2
- package/package.json +21 -4
- package/prototypes/iphone-voice-spike/argus_admin.py +55 -0
- package/prototypes/iphone-voice-spike/argus_phone_policy.py +164 -0
- package/prototypes/iphone-voice-spike/argus_task_ui.py +153 -0
- package/prototypes/iphone-voice-spike/hermes-admin-host.mjs +7 -0
- package/prototypes/iphone-voice-spike/hermes-admin.mjs +41 -0
- package/prototypes/iphone-voice-spike/hermes-admin.py +40 -0
- package/prototypes/iphone-voice-spike/hermes-bridge.mjs +167 -0
- package/prototypes/iphone-voice-spike/hermes-process-host.mjs +11 -0
- package/prototypes/iphone-voice-spike/hermes-worker.py +152 -0
- package/prototypes/iphone-voice-spike/mobile-auth.mjs +57 -0
- package/prototypes/iphone-voice-spike/mobile-server.mjs +110 -0
- package/prototypes/iphone-voice-spike/task-ui.mjs +25 -0
- package/scripts/argus-bridge.mjs +110 -0
- package/scripts/install-mobile-bridge.mjs +5 -0
- package/scripts/lib/mobile-install.mjs +79 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
I downloaded the Argus iPhone beta from TestFlight. Please connect it to my existing Hermes on this Mac or Linux/VPS host. Do the setup for me and keep the steps I need to do myself short.
|
|
2
|
+
|
|
3
|
+
Use the published Argus bridge package from npm with this exact command on the host where Hermes runs:
|
|
4
|
+
npx --yes argus-hermes-bridge@0.1.0 setup
|
|
5
|
+
The command includes the installer. You do not need to ask me for a separate download or private repository access. Use exactly this package name and version. If npm cannot retrieve it, report the actual error.
|
|
6
|
+
|
|
7
|
+
1. Inspect this host and locate the existing working Hermes installation, its Python runtime and config home. The beta supports macOS with a logged-in desktop user, or Linux with a working systemd user service manager. If Hermes runs in a container, Windows, or a restricted environment without these capabilities, explain the blocker before making changes. Use the same user account as Hermes; do not run the installer as root or reinstall Hermes.
|
|
8
|
+
2. Check Node.js 22+ and Tailscale. Help me install missing prerequisites using their official instructions. I need Tailscale on my iPhone and the Hermes host, signed into the same tailnet. Let me complete sign-in, HTTPS enablement, or administrator prompts myself. Preserve existing Tailscale routes. Use private Tailscale Serve, never public Funnel or an open internet port.
|
|
9
|
+
3. Inspect the package README from npm, then run the exact pinned setup command above. If a bridge already runs on this host, inspect its service before updating it and preserve existing device access. Finish active phone requests before a service restart. Pass ARGUS_HERMES_HOME, ARGUS_HERMES_SOURCE or ARGUS_HERMES_PYTHON only if auto-discovery needs the paths from this installation. Do not copy provider keys or passwords into chat, the phone, or the package. Keep the default strict phone approvals. Do not enable monitoring, automatic tool approvals, vault writes, or change my model/provider settings for setup.
|
|
10
|
+
4. Resolve setup errors, then run npx --yes argus-hermes-bridge@0.1.0 doctor. On Linux, check user-service lingering and help me enable it if needed so the bridge survives logout/reboot. On a Mac the user must stay logged in and the host must stay awake; explain any macOS file-access prompt. Do not claim a successful HTTPS check proves inference or tool approvals.
|
|
11
|
+
5. Issue the pairing code last, using npx --yes argus-hermes-bridge@0.1.0 pair if the setup code is close to expiry. Return its exact three-line block in one copyable code block:
|
|
12
|
+
ARGUS-PAIR/1
|
|
13
|
+
https://<actual-host>.<actual-tailnet>.ts.net:8443
|
|
14
|
+
<actual 12-character pairing code>
|
|
15
|
+
Do not invent these values. The code is single-use and expires in five minutes. Tell me to open Argus → Set up Argus, tap Paste, review the address and tap Pair this iPhone. If it expires, generate a fresh code without reinstalling.
|
|
16
|
+
6. Have me ask a harmless question in Argus using text first, then optionally set up voice. Confirm I received a real Hermes reply. Before testing tools, help me check a harmless tool request with Deny and then Approve once; do not execute external actions merely to test pairing. If anything cannot be verified, say exactly what is still needed.
|
package/README.md
CHANGED
|
@@ -1,3 +1,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Argus beta bridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Connect the Argus TestFlight app to **your own existing Hermes**. Supports a
|
|
4
|
+
logged-in macOS desktop user or Linux with systemd user services. Containers,
|
|
5
|
+
Windows, and hosts without a systemd user manager are outside this beta path.
|
|
6
|
+
|
|
7
|
+
## Start here
|
|
8
|
+
|
|
9
|
+
Open Argus → **Set up Argus** → **Copy setup prompt**. Send that prompt to
|
|
10
|
+
Hermes. The prompt includes the pinned npm command. Hermes can do the host setup and
|
|
11
|
+
return a three-line connection block for the phone. Install Tailscale on your
|
|
12
|
+
iPhone and sign in to the same tailnet as your Hermes host.
|
|
13
|
+
|
|
14
|
+
The companion package has no npm dependencies or install hooks. Downloading it
|
|
15
|
+
does not start a service; `setup` explicitly installs the bridge. It copies only
|
|
16
|
+
the bridge runtime into `~/.config/argus/service`, so it survives npx cache cleanup.
|
|
17
|
+
It does not need the private Argus repository.
|
|
18
|
+
|
|
19
|
+
## Run with npx
|
|
20
|
+
|
|
21
|
+
On the Hermes host, using its normal user account and Node.js 22 or newer:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npx --yes argus-hermes-bridge@0.1.0 setup
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package is [argus-hermes-bridge on npm](https://www.npmjs.com/package/argus-hermes-bridge).
|
|
28
|
+
Use `npx --yes argus-hermes-bridge@0.1.0` followed by any command below.
|
|
29
|
+
A supplied tarball also works with `npx --yes --package /path/to/package.tgz argus-bridge setup`.
|
|
30
|
+
|
|
31
|
+
`setup` finds the actual Tailscale hostname, checks runtime imports, installs a
|
|
32
|
+
user service, and adds a private HTTPS Serve route on port 8443 to the loopback
|
|
33
|
+
API on port 4179. It checks the authenticated API boundary locally and through
|
|
34
|
+
HTTPS before issuing a five-minute, one-use code. Review any system permission
|
|
35
|
+
or Tailscale HTTPS sign-in prompt yourself, then rerun `setup`.
|
|
36
|
+
|
|
37
|
+
On the phone, copy the **three-line ARGUS-PAIR/1 block**, open **Set up Argus**,
|
|
38
|
+
tap **Paste**, review the host and tap **Pair this iPhone**.
|
|
39
|
+
Only tapping Pair connects the phone; pasting does not make a request. Start
|
|
40
|
+
with a text question to confirm a real Hermes reply, then optionally enable voice.
|
|
41
|
+
|
|
42
|
+
## Runtime discovery and persistence
|
|
43
|
+
|
|
44
|
+
Hermes must already work. The installer checks `~/.hermes/hermes-agent` and its
|
|
45
|
+
virtual environments, then Hermes-managed Python under `~/.hermes/tools`. For a
|
|
46
|
+
custom installation, set these **paths** before the setup command:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
ARGUS_HERMES_HOME=/path/to/hermes-config \
|
|
50
|
+
ARGUS_HERMES_SOURCE=/path/to/hermes-agent \
|
|
51
|
+
ARGUS_HERMES_PYTHON=/path/to/venv/bin/python3 \
|
|
52
|
+
npx --yes argus-hermes-bridge@0.1.0 setup
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
These selections persist in the service definition and `~/.config/argus/bridge.json`.
|
|
56
|
+
Provider credentials stay in your existing Hermes configuration. Setup does not
|
|
57
|
+
select a different provider, enable monitoring, or authorize phone vault writes.
|
|
58
|
+
|
|
59
|
+
- **macOS:** LaunchAgent `com.tachyon.argus.mobile` starts at user login. Keep
|
|
60
|
+
the user logged in and the Mac awake. Logs are
|
|
61
|
+
`~/.config/argus/mobile-service{,-error}.log`. A Hermes installation under
|
|
62
|
+
Documents may require macOS file access; the installer cannot bypass it.
|
|
63
|
+
- **Linux/VPS:** `systemctl --user` manages `argus-mobile.service`; logs are
|
|
64
|
+
available with `journalctl --user -u argus-mobile.service`. To survive logout
|
|
65
|
+
and reboot, check `loginctl show-user "$(id -u)" --property=Linger --value`.
|
|
66
|
+
If needed, an administrator can run `sudo loginctl enable-linger "$(id -u)"`.
|
|
67
|
+
Setup reports when this step remains. Use the same user as Hermes, not root.
|
|
68
|
+
|
|
69
|
+
Keep Tailscale running on both devices. Setup preserves other Serve routes and
|
|
70
|
+
refuses a conflicting 8443 route or public Funnel on that port. It never
|
|
71
|
+
uses Funnel or opens the API on an internet-facing address. See the official
|
|
72
|
+
[Tailscale Serve command](https://tailscale.com/docs/reference/tailscale-cli/serve).
|
|
73
|
+
|
|
74
|
+
## Recovery and access
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
argus-bridge pair New code without reinstalling (invalidates the previous code)
|
|
78
|
+
argus-bridge doctor Service, runtime import and private HTTPS diagnostics
|
|
79
|
+
argus-bridge devices List paired devices
|
|
80
|
+
argus-bridge revoke ID Remove access for one listed device
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Run these with the same npx package prefix as setup. Transport and import checks
|
|
84
|
+
do not prove inference, each tool, or approval compatibility. Before beta tool
|
|
85
|
+
use and after every Hermes update, try a harmless tool request with **Deny** and
|
|
86
|
+
then **Approve once** in Argus. A blocked phone policy must be fixed on the
|
|
87
|
+
host; do not turn off Hermes approvals to bypass it.
|
|
88
|
+
|
|
89
|
+
To update the bridge, run `setup` with the new invitation package after finishing
|
|
90
|
+
active phone requests. Existing device access is retained. Updates are manual.
|
|
91
|
+
|
|
92
|
+
## Remove the bridge
|
|
93
|
+
|
|
94
|
+
Stop the service on macOS with
|
|
95
|
+
`launchctl bootout gui/$(id -u)/com.tachyon.argus.mobile`, then remove
|
|
96
|
+
`~/Library/LaunchAgents/com.tachyon.argus.mobile.plist`. On Linux use
|
|
97
|
+
`systemctl --user disable --now argus-mobile.service`, remove
|
|
98
|
+
`~/.config/systemd/user/argus-mobile.service`, and run
|
|
99
|
+
`systemctl --user daemon-reload`.
|
|
100
|
+
|
|
101
|
+
If 8443 still points only to Argus, remove that dedicated route with
|
|
102
|
+
`tailscale serve --https=8443 off` using your installed Tailscale executable.
|
|
103
|
+
Do not reset the host's other Serve routes. Remove `~/.config/argus/service`
|
|
104
|
+
and `bridge.json` if desired. `mobile-devices.json` contains the separate
|
|
105
|
+
device registry; deleting it revokes all phones. Hermes itself stays installed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argus-hermes-bridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Private Tailscale connection between the Argus iPhone beta and your existing Hermes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"os": [
|
|
11
|
+
"darwin",
|
|
12
|
+
"linux"
|
|
13
|
+
],
|
|
14
|
+
"bin": {
|
|
15
|
+
"argus-bridge": "scripts/argus-bridge.mjs"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"scripts",
|
|
19
|
+
"prototypes",
|
|
20
|
+
"HermesSetupPrompt.txt",
|
|
21
|
+
"README.md"
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Small adapters to Hermes' public model catalog and encrypted vault store.
|
|
2
|
+
|
|
3
|
+
Used by hermes-admin.py (phone model settings and Add Login) and by
|
|
4
|
+
hermes-worker.py (validating a per-phone model choice before a request runs).
|
|
5
|
+
No credential value or conversation content is returned by these operations.
|
|
6
|
+
"""
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def catalog(default, provider, models):
|
|
11
|
+
def valid(value):
|
|
12
|
+
return isinstance(value, str) and 0 < len(value) <= 200 and not any(ord(c) < 32 for c in value)
|
|
13
|
+
if not valid(default) or not valid(provider):
|
|
14
|
+
raise ValueError('Invalid configured model')
|
|
15
|
+
choices = list(dict.fromkeys([default] + [m for m in models if valid(m)]))[:120]
|
|
16
|
+
return {'provider': provider, 'defaultModel': default, 'models': choices}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def selected_model(default, provider, override, provider_model_ids):
|
|
20
|
+
"""Validate a stale per-phone override before a conversation runs."""
|
|
21
|
+
if not override or override == default:
|
|
22
|
+
return default
|
|
23
|
+
if override not in provider_model_ids(provider):
|
|
24
|
+
raise ValueError('The selected model is no longer available for this configured provider')
|
|
25
|
+
return override
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save_login(payload, store):
|
|
29
|
+
if set(payload) != {'origin', 'label', 'identifier', 'password'}:
|
|
30
|
+
raise ValueError('Invalid login fields')
|
|
31
|
+
for key, limit in [('origin', 500), ('label', 80), ('identifier', 320), ('password', 4096)]:
|
|
32
|
+
value = payload[key]
|
|
33
|
+
if not isinstance(value, str) or not value or len(value) > limit or '\x00' in value:
|
|
34
|
+
raise ValueError('Invalid login')
|
|
35
|
+
origin = urlsplit(payload['origin'])
|
|
36
|
+
if origin.scheme != 'https' or not origin.hostname or origin.username or origin.password or origin.query or origin.fragment or origin.path not in ('', '/'):
|
|
37
|
+
raise ValueError('HTTPS origin required')
|
|
38
|
+
identifier = payload['identifier'].strip()
|
|
39
|
+
if not identifier or not payload['label'].strip():
|
|
40
|
+
raise ValueError('Login identifier and label required')
|
|
41
|
+
secret = {'identifier': identifier, 'identifier_type': 'email' if '@' in identifier else 'username', 'password': payload['password']}
|
|
42
|
+
try:
|
|
43
|
+
meta = store.add_item('login', payload['label'].strip(), secret, origin=payload['origin'])
|
|
44
|
+
return {'status': 'saved', 'handle': meta.id, 'origin': meta.origin, 'storage': 'hermes-local'}
|
|
45
|
+
finally:
|
|
46
|
+
secret.clear()
|
|
47
|
+
payload.clear()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def existing_vault_guard(store):
|
|
51
|
+
"""Require existing storage and prohibit key generation even if the key disappears mid-save."""
|
|
52
|
+
from unittest.mock import patch
|
|
53
|
+
if not store._key_path.is_file() or not store._vault_path.is_file():
|
|
54
|
+
raise RuntimeError('Initialize the Hermes vault on the Mac before granting phone writes')
|
|
55
|
+
return patch('cryptography.fernet.Fernet.generate_key', side_effect=RuntimeError('Phone saves cannot provision a vault key'))
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Argus phone policy for full Hermes tools: fail closed, and ask the phone first.
|
|
2
|
+
|
|
3
|
+
A paired iPhone reaches Hermes with the Hermes host's configured toolsets. On its own,
|
|
4
|
+
Hermes prompts only for actions its pattern checks flag, and `approvals.mode: off`
|
|
5
|
+
or yolo approve everything without asking. This module refuses to run in those
|
|
6
|
+
states and registers a `pre_tool_call` hook that sends every tool outside a short
|
|
7
|
+
read-only list through Hermes's own human-approval gate, which the phone answers
|
|
8
|
+
with Approve once / Deny. Hook escalations skip Hermes's smart (LLM) approver, so
|
|
9
|
+
a person always decides. In host-selected personal monitor mode, the hook
|
|
10
|
+
records only tool identifiers/classifications and returns no directive; Hermes
|
|
11
|
+
keeps its own configured approvals and other plugin policies. Both modes refuse
|
|
12
|
+
bypass/unattended configurations. Anything this module cannot verify refuses.
|
|
13
|
+
|
|
14
|
+
Checked against hermes-agent main at b2360086; an installed Hermes that lacks
|
|
15
|
+
these interfaces refuses phone mode instead of running ungated.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
# Tools that only read public information or manage Hermes's own turn. Reading
|
|
21
|
+
# local files is not here: file contents could leave the Hermes host through the model
|
|
22
|
+
# provider or a later web request.
|
|
23
|
+
READ_ONLY_TOOLS = frozenset({
|
|
24
|
+
'clarify', 'todo_list', 'web_search', 'web_extract', 'x_search', 'skills_list', 'skill_view',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
PLAIN_ACTIONS = {
|
|
28
|
+
'terminal': 'run a command on your Hermes host',
|
|
29
|
+
'execute_code': 'run a program on your Hermes host',
|
|
30
|
+
'write_file': 'change a file on your Hermes host',
|
|
31
|
+
'patch': 'change a file on your Hermes host',
|
|
32
|
+
'read_file': 'read a file on your Hermes host',
|
|
33
|
+
'search_files': 'search the files on your Hermes host',
|
|
34
|
+
'computer_use': "control your host's screen, mouse and keyboard",
|
|
35
|
+
'memory': "change what Hermes remembers",
|
|
36
|
+
'session_search': 'search your past Hermes conversations',
|
|
37
|
+
'cronjob_manage': 'schedule a task on your Hermes host',
|
|
38
|
+
'delegate_task': 'start a helper agent',
|
|
39
|
+
'send_message': 'send a message',
|
|
40
|
+
'react_to_message': 'react to a message',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def describe(tool_name, args):
|
|
45
|
+
"""Plain words for the approval card, followed by the exact arguments."""
|
|
46
|
+
name = str(tool_name)
|
|
47
|
+
if name in PLAIN_ACTIONS:
|
|
48
|
+
action = PLAIN_ACTIONS[name]
|
|
49
|
+
elif name.startswith('browser'):
|
|
50
|
+
action = 'use the web browser on your Hermes host'
|
|
51
|
+
else:
|
|
52
|
+
action = f'use the tool "{name}"'
|
|
53
|
+
try:
|
|
54
|
+
detail = json.dumps(args, ensure_ascii=False, default=str)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
detail = repr(args)
|
|
57
|
+
if len(detail) > 600:
|
|
58
|
+
detail = detail[:600] + '…'
|
|
59
|
+
return f'Hermes wants to {action}. Details: {detail}'
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def gate(tool_name='', args=None, **_):
|
|
63
|
+
"""`pre_tool_call` hook. Must never raise: Hermes treats a failing guard as no decision."""
|
|
64
|
+
try:
|
|
65
|
+
if tool_name in READ_ONLY_TOOLS:
|
|
66
|
+
return None
|
|
67
|
+
# A fresh rule key per call: no earlier "always" or session approval can match it.
|
|
68
|
+
return {'action': 'approve', 'message': describe(tool_name, args),
|
|
69
|
+
'rule_key': 'argus-phone-' + uuid.uuid4().hex}
|
|
70
|
+
except Exception:
|
|
71
|
+
return {'action': 'block', 'message': 'Argus could not describe this action for approval, so it did not run.'}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def refusal():
|
|
75
|
+
"""Why phone mode must not run right now, or None. Any doubt refuses."""
|
|
76
|
+
try:
|
|
77
|
+
from tools import approval, approval_context
|
|
78
|
+
from tools.terminal_tool import _get_approval_callback
|
|
79
|
+
mode = approval_context._get_approval_mode()
|
|
80
|
+
if mode not in ('manual', 'smart'):
|
|
81
|
+
return (f'Hermes approvals.mode is "{mode}", which approves actions without asking. '
|
|
82
|
+
'Set approvals.mode to manual or smart in ~/.hermes/config.yaml.')
|
|
83
|
+
if approval._YOLO_MODE_FROZEN or approval.is_current_session_yolo_enabled():
|
|
84
|
+
return 'Hermes yolo mode is on, which approves actions without asking. Remove HERMES_YOLO_MODE from the service.'
|
|
85
|
+
aggregate = getattr(approval_context, '_no_user_can_answer', None)
|
|
86
|
+
if aggregate is not None:
|
|
87
|
+
unattended = aggregate()
|
|
88
|
+
else:
|
|
89
|
+
# Some Hermes releases expose the three context checks separately.
|
|
90
|
+
# Require all three: missing checks still fail closed below.
|
|
91
|
+
checks = [approval_context._is_single_query_approval_context,
|
|
92
|
+
approval_context._is_cron_approval_context,
|
|
93
|
+
approval_context._is_unattended_platform_approval_context]
|
|
94
|
+
unattended = any(check() for check in checks)
|
|
95
|
+
if unattended:
|
|
96
|
+
return 'This Hermes process is marked single-query, cron or unattended, so no one could answer an approval.'
|
|
97
|
+
if not approval_context._is_interactive_cli():
|
|
98
|
+
return 'Hermes is not in interactive mode, so approvals could not reach the phone.'
|
|
99
|
+
if _get_approval_callback() is None:
|
|
100
|
+
return 'No phone approval callback is registered.'
|
|
101
|
+
except Exception as error:
|
|
102
|
+
return f'Could not verify Hermes approval settings ({type(error).__name__}). This Hermes version may not match Argus.'
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _hooks():
|
|
107
|
+
from hermes_cli import plugins
|
|
108
|
+
return plugins.get_plugin_manager()._hooks.setdefault('pre_tool_call', [])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MonitorGate:
|
|
112
|
+
"""Observe Argus's tool classification without adding an approval directive."""
|
|
113
|
+
def __init__(self, report):
|
|
114
|
+
self.report = report
|
|
115
|
+
self.probing = False
|
|
116
|
+
self.probe_seen = False
|
|
117
|
+
|
|
118
|
+
def __call__(self, tool_name='', args=None, **_):
|
|
119
|
+
if self.probing:
|
|
120
|
+
self.probe_seen = True
|
|
121
|
+
else:
|
|
122
|
+
# No argument values, command text, transcript or credentials in observations.
|
|
123
|
+
self.report({'tool': str(tool_name)[:80],
|
|
124
|
+
'outcome': 'read_only' if tool_name in READ_ONLY_TOOLS else 'would_request_approval'})
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def install(mode='strict', report=None):
|
|
129
|
+
"""Install only the host-selected mode; Hermes's approvals remain unchanged."""
|
|
130
|
+
if mode == 'monitor' and not callable(report):
|
|
131
|
+
raise ValueError('Monitor requires an observation callback')
|
|
132
|
+
if mode not in ('strict', 'monitor'):
|
|
133
|
+
raise ValueError('Unknown Argus guardrail mode')
|
|
134
|
+
from hermes_cli import plugins
|
|
135
|
+
plugins.get_plugin_manager().discover_and_load()
|
|
136
|
+
hook = gate if mode == 'strict' else MonitorGate(report)
|
|
137
|
+
hooks = _hooks()
|
|
138
|
+
if hook not in hooks:
|
|
139
|
+
hooks.append(hook)
|
|
140
|
+
verify(hook)
|
|
141
|
+
return hook
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def verify(hook=gate):
|
|
145
|
+
from hermes_cli.plugins import _get_pre_tool_call_directive_details
|
|
146
|
+
if not still_installed(hook):
|
|
147
|
+
raise RuntimeError('Argus phone hook is not registered')
|
|
148
|
+
if isinstance(hook, MonitorGate):
|
|
149
|
+
hook.probing, hook.probe_seen = True, False
|
|
150
|
+
try:
|
|
151
|
+
_get_pre_tool_call_directive_details('terminal', {'command': 'true'})
|
|
152
|
+
if not hook.probe_seen:
|
|
153
|
+
raise RuntimeError('Hermes did not dispatch the Argus monitor')
|
|
154
|
+
finally:
|
|
155
|
+
hook.probing = False
|
|
156
|
+
elif _get_pre_tool_call_directive_details('terminal', {'command': 'true'}).action not in ('approve', 'block'):
|
|
157
|
+
raise RuntimeError('Hermes did not route a tool call through the Argus approval gate')
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def still_installed(hook=gate):
|
|
161
|
+
try:
|
|
162
|
+
return hook in _hooks()
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Bounded presentation contract; no layouts, code, styles or action authority.
|
|
2
|
+
|
|
3
|
+
Hermes answers the phone with {answer, ui}. `ui` is `argus-ui/1`: a title, up to
|
|
4
|
+
six label/value cards, and one question with visible reply choices. Four
|
|
5
|
+
optional sections extend a finished answer: `steps`, `list`, `notice` and
|
|
6
|
+
`sources`. Everything is plain text that the phone draws with its own styles.
|
|
7
|
+
The only URLs allowed anywhere are the https pages Hermes actually read with a
|
|
8
|
+
tool while answering (or that the spoken answer already names), so a card can
|
|
9
|
+
never invent a citation. Anything that breaks a bound is dropped, never repaired
|
|
10
|
+
into a guess, and the plain answer still arrives.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
|
|
16
|
+
# Decorative SF Symbols a card may name. The Mac removes unknown names before the
|
|
17
|
+
# phone sees them; the phone and bridge treat any other name as invalid.
|
|
18
|
+
SYMBOLS = frozenset({
|
|
19
|
+
'thermometer', 'cloud.sun', 'cloud.rain', 'sun.max', 'moon', 'wind', 'drop',
|
|
20
|
+
'clock', 'calendar', 'bell', 'mappin', 'figure.walk', 'car', 'bus', 'tram',
|
|
21
|
+
'fork.knife', 'cup.and.saucer', 'leaf', 'house', 'bed.double', 'pills', 'heart',
|
|
22
|
+
'stethoscope', 'dollarsign', 'creditcard', 'phone', 'envelope', 'message',
|
|
23
|
+
'person', 'person.2', 'globe', 'book', 'checkmark', 'exclamationmark.triangle',
|
|
24
|
+
'info.circle', 'questionmark.circle', 'star', 'tag', 'lightbulb', 'shield',
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
PRESENTATION_PROMPT = '''For your final response, return only a JSON object with keys answer and ui.
|
|
28
|
+
answer is your concise plain-text spoken answer (at most 120 words). Argus reads it aloud and shows it as the lead paragraph, so put no headings, bullet lists, tables, code or links in it.
|
|
29
|
+
ui is {"schema":"argus-ui/1","title":"Short task-specific title","cards":[{"label":"Short label","value":"Useful factual detail","symbol":"thermometer"}],"steps":["First step","Second step"],"list":[{"title":"Option","detail":"Why it fits"}],"notice":"What was not checked live, or a caveat","sources":[{"title":"Page title","url":"https://example.org/page"}],"question":"The single next useful preference question, or null","choices":["Visible reply one","Visible reply two"]}.
|
|
30
|
+
Use at most 6 cards (label <=80 characters, value <=600), a title <=120, a question <=240, and at most 6 distinct choices <=160 characters. Empty cards/choices are fine.
|
|
31
|
+
steps, list, notice and sources are optional: leave each out unless this answer needs it. steps: up to 8 short strings in order. list: up to 8 items with a short title (<=80) and an optional detail (<=200). notice: one sentence (<=240) naming what was not checked live or a caveat. sources: up to 5 pages you actually read with a tool while answering this request, with their exact https URLs; never invent a source, and never put URLs anywhere else in ui. symbol is optional on a card and must be one of: ''' + ', '.join(sorted(SYMBOLS)) + '''.
|
|
32
|
+
The spoken answer should include the next question when there is one. Use unique card labels. Compose only what this actual task currently needs; preserve user choices from the conversation. Do not repeat the spoken answer in every card. Never invent live facts or completed actions. Choices are conversational preferences, not commands or confirmations: no approve, confirm, send, delete, purchase, pay, book, execute or run-command choices. Actions and approvals must use the existing clarify/approval tools, never presentation data. No Markdown fences, HTML, code, colors, layout instructions or extra keys in ui. If structure is unsuitable, ui may be null.'''
|
|
33
|
+
|
|
34
|
+
CONVERSATION_ONLY_NOTE = ' No tools are available in this connection: never include sources, and say in notice when a live fact was not checked.'
|
|
35
|
+
|
|
36
|
+
FORBIDDEN_CHOICE = re.compile(r'\b(approve|confirm|send|delete|purchase|pay|book|execute|run command)\b', re.I)
|
|
37
|
+
_URL = re.compile(r'https://[^\s<>"\'\]\)\}]+')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def plain(value, limit):
|
|
41
|
+
return isinstance(value, str) and bool(value.strip()) and len(value) <= limit and not re.search(r'[<>\x00-\x1f]', value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def source_url(value):
|
|
45
|
+
"""An https URL without credentials, whitespace or quote characters, or None."""
|
|
46
|
+
if not isinstance(value, str) or len(value) > 300 or re.search(r'[\s<>"\']', value):
|
|
47
|
+
return None
|
|
48
|
+
parts = urlsplit(value)
|
|
49
|
+
if parts.scheme != 'https' or not parts.hostname or parts.username or parts.password:
|
|
50
|
+
return None
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def urls_in(value, limit=20000):
|
|
55
|
+
"""https URLs mentioned in a tool result or answer. Only a bounded prefix is scanned."""
|
|
56
|
+
if value is None:
|
|
57
|
+
return set()
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
try:
|
|
60
|
+
value = json.dumps(value, ensure_ascii=False, default=str)
|
|
61
|
+
except (TypeError, ValueError):
|
|
62
|
+
value = repr(value)
|
|
63
|
+
found = set()
|
|
64
|
+
for match in _URL.findall(value[:limit]):
|
|
65
|
+
url = source_url(match.rstrip('.,;:!?'))
|
|
66
|
+
if url:
|
|
67
|
+
found.add(url)
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unique_texts(values, limit, count):
|
|
72
|
+
return (isinstance(values, list) and 1 <= len(values) <= count
|
|
73
|
+
and all(plain(v, limit) for v in values) and len(set(values)) == len(values))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def valid_ui(ui):
|
|
77
|
+
return (isinstance(ui, dict) and set(ui) <= {'schema', 'title', 'cards', 'question', 'choices', 'steps', 'list', 'notice', 'sources'}
|
|
78
|
+
and ui.get('schema') == 'argus-ui/1' and plain(ui.get('title'), 120)
|
|
79
|
+
and isinstance(ui.get('cards'), list) and len(ui['cards']) <= 6
|
|
80
|
+
and all(isinstance(c, dict) and {'label', 'value'} <= set(c) <= {'label', 'value', 'symbol'}
|
|
81
|
+
and plain(c['label'], 80) and plain(c['value'], 600) and ('symbol' not in c or c['symbol'] in SYMBOLS) for c in ui['cards'])
|
|
82
|
+
and len(set(c['label'] for c in ui['cards'])) == len(ui['cards'])
|
|
83
|
+
and ('steps' not in ui or _unique_texts(ui['steps'], 200, 8))
|
|
84
|
+
and ('list' not in ui or (isinstance(ui['list'], list) and 1 <= len(ui['list']) <= 8
|
|
85
|
+
and all(isinstance(i, dict) and {'title'} <= set(i) <= {'title', 'detail'} and plain(i['title'], 80)
|
|
86
|
+
and ('detail' not in i or plain(i['detail'], 200)) for i in ui['list'])))
|
|
87
|
+
and ('notice' not in ui or plain(ui['notice'], 240))
|
|
88
|
+
and ('sources' not in ui or (isinstance(ui['sources'], list) and 1 <= len(ui['sources']) <= 5
|
|
89
|
+
and all(isinstance(s, dict) and set(s) == {'title', 'url'} and plain(s['title'], 80) and source_url(s['url']) for s in ui['sources'])
|
|
90
|
+
and len(set(s['url'] for s in ui['sources'])) == len(ui['sources'])))
|
|
91
|
+
and (ui.get('question') is None or plain(ui['question'], 240))
|
|
92
|
+
and isinstance(ui.get('choices'), list) and len(ui['choices']) <= 6
|
|
93
|
+
and all(plain(c, 160) and not FORBIDDEN_CHOICE.search(c) for c in ui['choices'])
|
|
94
|
+
and len(set(ui['choices'])) == len(ui['choices'])
|
|
95
|
+
and (not ui['choices'] or plain(ui.get('question'), 240)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def prune(ui, allowed_urls=()):
|
|
99
|
+
"""Remove what the model does not get to decide, then leave the rest to valid_ui.
|
|
100
|
+
|
|
101
|
+
Empty optional sections disappear, an unknown symbol is removed from its card,
|
|
102
|
+
and a source survives only when Hermes read that page (or the spoken answer
|
|
103
|
+
names it). Nothing else is repaired: a bound that is still broken drops the
|
|
104
|
+
whole ui and the plain answer stands alone.
|
|
105
|
+
"""
|
|
106
|
+
if not isinstance(ui, dict):
|
|
107
|
+
return ui
|
|
108
|
+
ui = dict(ui)
|
|
109
|
+
for key in ('steps', 'list', 'notice', 'sources'):
|
|
110
|
+
if key in ui and not ui[key]:
|
|
111
|
+
del ui[key]
|
|
112
|
+
if isinstance(ui.get('cards'), list):
|
|
113
|
+
ui['cards'] = [{k: v for k, v in card.items() if k != 'symbol' or v in SYMBOLS} if isinstance(card, dict) else card
|
|
114
|
+
for card in ui['cards']]
|
|
115
|
+
if isinstance(ui.get('sources'), list):
|
|
116
|
+
allowed, kept, seen = set(allowed_urls), [], set()
|
|
117
|
+
for source in ui['sources']:
|
|
118
|
+
url = source_url(source.get('url')) if isinstance(source, dict) else None
|
|
119
|
+
if not url or url not in allowed or url in seen:
|
|
120
|
+
continue
|
|
121
|
+
title = source.get('title') if plain(source.get('title'), 80) else (urlsplit(url).hostname or url)[:80]
|
|
122
|
+
kept.append({'title': title, 'url': url})
|
|
123
|
+
seen.add(url)
|
|
124
|
+
if kept:
|
|
125
|
+
ui['sources'] = kept
|
|
126
|
+
else:
|
|
127
|
+
del ui['sources']
|
|
128
|
+
return ui
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def presentation(final_response, allowed_urls=()):
|
|
132
|
+
"""Split Hermes' final response into the spoken answer and a validated ui.
|
|
133
|
+
|
|
134
|
+
`allowed_urls` are the https pages Hermes actually read with a tool during
|
|
135
|
+
this request; together with any URL the spoken answer names, they are the
|
|
136
|
+
only sources a card may cite.
|
|
137
|
+
"""
|
|
138
|
+
text = final_response.strip()
|
|
139
|
+
candidate = text
|
|
140
|
+
if text.startswith('```') and text.endswith('```'):
|
|
141
|
+
candidate = text.split('\n', 1)[-1].rsplit('```', 1)[0].strip()
|
|
142
|
+
try:
|
|
143
|
+
envelope = json.loads(candidate)
|
|
144
|
+
except (ValueError, TypeError):
|
|
145
|
+
envelope = None
|
|
146
|
+
if isinstance(envelope, dict) and isinstance(envelope.get('answer'), str) and envelope['answer'].strip():
|
|
147
|
+
answer = envelope['answer'].strip()
|
|
148
|
+
ui = envelope.get('ui')
|
|
149
|
+
else:
|
|
150
|
+
answer, ui = text, None
|
|
151
|
+
ui = prune(ui, set(allowed_urls) | urls_in(answer))
|
|
152
|
+
return {'answer': answer if len(answer) <= 4000 else answer[:3900] + '\n[Long answer shortened on iPhone.]',
|
|
153
|
+
'ui': ui if valid_ui(ui) else None}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { parentPort,workerData } from 'node:worker_threads';
|
|
2
|
+
import { runHermesAdminDirect } from './hermes-admin.mjs';
|
|
3
|
+
let run,cancelled=false;
|
|
4
|
+
parentPort.on('message',message=>{if(message.cancel){cancelled=true;run?.cancel();}});
|
|
5
|
+
try{run=runHermesAdminDirect(workerData);if(cancelled)run.cancel();const value=await run.result;parentPort.postMessage({value});}
|
|
6
|
+
catch(error){parentPort.postMessage({failed:true,terminationConfirmed:!run||error?.terminationConfirmed===true});}
|
|
7
|
+
parentPort.close();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { readdirSync,existsSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
const unconfirmed=(terminationConfirmed=false)=>Object.assign(new Error('Administrative outcome unconfirmed.'),{terminationConfirmed});
|
|
8
|
+
export function runHermesAdminDirect(input) {
|
|
9
|
+
const home=process.env.ARGUS_HERMES_HOME||resolve(homedir(),'.hermes');
|
|
10
|
+
const executable=process.env.ARGUS_HERMES_PYTHON||readdirSync(resolve(home,'tools')).filter(n=>n.startsWith('python-')).sort().reverse().map(n=>resolve(home,'tools',n,'bin/python3')).find(existsSync);
|
|
11
|
+
if(!executable)throw unconfirmed(true);
|
|
12
|
+
const child=spawn(executable,['-I',fileURLToPath(new URL('./hermes-admin.py',import.meta.url))],{stdio:['pipe','pipe','pipe']});
|
|
13
|
+
let output='',stopping=false,cancelRun;
|
|
14
|
+
const result=new Promise((accept,reject)=>{
|
|
15
|
+
let killTimer;
|
|
16
|
+
const stop=()=>{if(stopping)return;stopping=true;child.kill('SIGTERM');killTimer=setTimeout(()=>child.kill('SIGKILL'),2000);};
|
|
17
|
+
cancelRun=stop;
|
|
18
|
+
const timer=setTimeout(stop,20000);
|
|
19
|
+
child.stdout.on('data',chunk=>{output+=chunk;if(output.length>64000)stop();});
|
|
20
|
+
child.stderr.resume();
|
|
21
|
+
child.stdin.on('error',stop);child.on('error',stop);
|
|
22
|
+
// Settle only after close: cancellation is not proof that the child can no longer write.
|
|
23
|
+
child.on('close',code=>{clearTimeout(timer);clearTimeout(killTimer);if(stopping){reject(unconfirmed(true));return;}
|
|
24
|
+
try{const value=JSON.parse(output);if(code!==0||!value||value.error)throw new Error();accept(value);}catch{reject(unconfirmed(true));}});
|
|
25
|
+
child.stdin.end(JSON.stringify(input)+'\n');
|
|
26
|
+
});
|
|
27
|
+
return {result,cancel:()=>cancelRun(),pid:child.pid};
|
|
28
|
+
}
|
|
29
|
+
const activeHosts=new Set();
|
|
30
|
+
export function runHermesAdmin(input) {
|
|
31
|
+
if(activeHosts.size>=2)throw unconfirmed(true);
|
|
32
|
+
const host=new Worker(new URL('./hermes-admin-host.mjs',import.meta.url),{workerData:input});activeHosts.add(host);
|
|
33
|
+
let settled=false;
|
|
34
|
+
const result=new Promise((accept,reject)=>{
|
|
35
|
+
const timer=setTimeout(()=>{if(!settled)host.postMessage({cancel:true});},25000);
|
|
36
|
+
host.on('message',message=>{if(settled)return;settled=true;clearTimeout(timer);message.value?accept(message.value):reject(unconfirmed(message.terminationConfirmed===true));});
|
|
37
|
+
host.on('error',()=>{clearTimeout(timer);if(!settled){settled=true;reject(unconfirmed(false));}});
|
|
38
|
+
host.on('exit',()=>{activeHosts.delete(host);clearTimeout(timer);if(!settled){settled=true;reject(unconfirmed(false));}});
|
|
39
|
+
});
|
|
40
|
+
return {result,cancel:()=>host.postMessage({cancel:true})};
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Isolated administrative operations. Secret input uses stdin only; output is metadata only."""
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
wire = sys.stdout
|
|
9
|
+
home = Path(os.environ.get('ARGUS_HERMES_HOME', Path.home() / '.hermes')).resolve()
|
|
10
|
+
source = Path(os.environ.get('ARGUS_HERMES_SOURCE', home / 'hermes-agent')).resolve()
|
|
11
|
+
os.environ['HERMES_HOME'] = str(home)
|
|
12
|
+
sys.path.insert(0, str(source))
|
|
13
|
+
sys.path.append(str(Path(__file__).resolve().parent)) # -I omits the script folder
|
|
14
|
+
value = None
|
|
15
|
+
try:
|
|
16
|
+
request = json.loads(sys.stdin.readline(64001))
|
|
17
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
18
|
+
import hermes_bootstrap # the same dependency activation hermes-worker.py uses
|
|
19
|
+
from argus_admin import catalog, save_login, existing_vault_guard
|
|
20
|
+
action = request.get('action')
|
|
21
|
+
if action == 'save_login':
|
|
22
|
+
from agent.vault_store import get_vault_store
|
|
23
|
+
store = get_vault_store()
|
|
24
|
+
with existing_vault_guard(store):
|
|
25
|
+
value = save_login(request['login'], store)
|
|
26
|
+
elif action == 'catalog':
|
|
27
|
+
from hermes_cli.config import load_config
|
|
28
|
+
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
29
|
+
from hermes_cli.models import provider_model_ids
|
|
30
|
+
config = load_config()
|
|
31
|
+
default = config['model']['default']
|
|
32
|
+
runtime = resolve_runtime_provider(requested=config['model'].get('provider'), target_model=default)
|
|
33
|
+
value = catalog(default, runtime['provider'], provider_model_ids(runtime['provider']))
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError('Unknown administrative operation')
|
|
36
|
+
except Exception:
|
|
37
|
+
# Never serialize exceptions: provider/vault failures may contain sensitive values.
|
|
38
|
+
value = {'error': 'Hermes administrative operation did not complete.'}
|
|
39
|
+
wire.write(json.dumps(value, ensure_ascii=False) + '\n')
|
|
40
|
+
wire.flush()
|