@zlnap/astrolabe 0.16.0 → 0.17.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.
@@ -0,0 +1,93 @@
1
+ # astrolabe-dsh-guard — Astrolabe drift guard for DeepSeek Harness
2
+
3
+ A **native DeepSeek Harness (DSH) hook plugin** that runs `astrolabe hooks remind`
4
+ on every relevant tool call and denies the call when Astrolabe says the agent has
5
+ drifted into repeated `grep`/`read` instead of using Astrolabe's MCP tools.
6
+
7
+ Unlike the Claude Code marketplace plugin in `../claude-plugin` (which only
8
+ registers the MCP server), this package is for **DSH hosts**, whose hook surface
9
+ is a Cordis plugin API rather than a `hooks.json` file.
10
+
11
+ ## Why a native plugin
12
+
13
+ DSH's own design note is explicit:
14
+
15
+ > "native hooks" are not a package — a native hook is just an ordinary Cordis
16
+ > plugin subscribing to the canonical lifecycle events. … Anything a bridge can
17
+ > do, a plain plugin can do directly — more powerfully (no serialization
18
+ > boundary, full `ctx`, typed returns).
19
+
20
+ Running Astrolabe's hook through `@deepseek-ai/dsh-hooks-claude-code` also works,
21
+ but that path needs a third-party bridge package, a `hooks.json`, and a wrapper
22
+ script. This plugin needs none of them.
23
+
24
+ ## Requirements
25
+
26
+ - `astrolabe` on `PATH` (see the repository README), or set `config.binary` to an
27
+ absolute path.
28
+ - A DSH profile where the plugin can be loaded (see *Install* below).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ # 1. put the package where the DSH profile resolves modules
34
+ cp -R dsh-plugin "$HOME/.dsh/profiles/desktop/node_modules/astrolabe-dsh-guard"
35
+ ```
36
+
37
+ ```yaml
38
+ # 2. ~/.dsh/cordis.patch.yml — insert the plugin
39
+ - insert:
40
+ - id: astrolabe-guard
41
+ name: 'astrolabe-dsh-guard'
42
+ config:
43
+ client: claude-code
44
+ ```
45
+
46
+ DSH hot-reloads the patch file; no restart is required.
47
+
48
+ > The package declares `dsh.bundle`, so a DSH plugin manager that installs
49
+ > bundles can also take it (from a git URL or a local path) and manage it as a
50
+ > normal entry.
51
+
52
+ ## Configuration
53
+
54
+ | Key | Default | Meaning |
55
+ |---|---|---|
56
+ | `binary` | `astrolabe` | Executable to run; an absolute path is safest |
57
+ | `client` | `claude-code` | `--client` dialect passed to `astrolabe hooks remind` |
58
+ | `timeoutMs` | `20000` | Per-call timeout for the hook process |
59
+ | `stateDir` | `<tmpdir>/astrolabe-dsh-guard` | `HOME` for the hook process; Astrolabe writes its counter under `$HOME/.astrolabe` |
60
+
61
+ ## The sandbox detail that matters
62
+
63
+ DSH runs hook commands inside its file sandbox. The user's home is readable but
64
+ **not writable** there, so Astrolabe's counter write to `~/.astrolabe/hook_data/`
65
+ fails and the hook silently fails open — the guard appears to be installed but
66
+ never denies anything.
67
+
68
+ The plugin therefore points the hook process at a writable `HOME`
69
+ (`stateDir`) instead of widening the sandbox. Counters live under
70
+ `<tmpdir>/astrolabe-dsh-guard/.astrolabe/hook_data/<session_id>/counter.json`
71
+ and reset when the OS clears the temp directory.
72
+
73
+ ## Behaviour
74
+
75
+ - Tools observed: `grep`, `read`, `glob`, `bash`/`shell`, `rg`, plus every
76
+ Astrolabe MCP tool (so that using Astrolabe resets the counter).
77
+ - Default threshold: the 3rd consecutive `grep`/`read` without an intervening
78
+ symbolic Astrolabe call is denied, with Astrolabe's own reason text forwarded
79
+ to the model.
80
+ - Any failure (missing binary, timeout, unparsable output, sandbox fault) **fails
81
+ open**: the tool call proceeds.
82
+
83
+ ## Notes
84
+
85
+ - Astrolabe identifies its own tools by substring, so DSH's per-repository server
86
+ names (`mcp__astrolabe-<repo>__find_symbol`) need no rewriting.
87
+ - `search_code` is a *non-symbolic* Astrolabe tool: it does not reset the
88
+ grep/read counter (by Astrolabe's own design). Use `find_symbol` /
89
+ `resolve_context` / `find_references` / `goto_definition` to reset it.
90
+
91
+ ## Licence
92
+
93
+ MIT OR Apache-2.0, matching the rest of Astrolabe.
@@ -0,0 +1,11 @@
1
+ # Bundle patch for DeepSeek Harness: inserts the Astrolabe drift guard plugin.
2
+ #
3
+ # Installed through the harness plugin manager (it declares `dsh.bundle`), or
4
+ # copied into a profile and referenced from the profile's own cordis.patch.yml.
5
+ # `astrolabe` must be on PATH for the plugin's `shell` calls; override the
6
+ # binary path with `config.binary` when it is not.
7
+ - insert:
8
+ - id: astrolabe-guard
9
+ name: 'astrolabe-dsh-guard'
10
+ config:
11
+ client: claude-code
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Astrolabe drift guard for DeepSeek Harness — native hook plugin.
3
+ *
4
+ * DeepSeek Harness has no hook config file of its own: a "native hook" is an
5
+ * ordinary Cordis plugin subscribing to the canonical interception events, and
6
+ * the `dsh-hooks-claude-code` / `dsh-hooks-codex` packages are only translators
7
+ * that map an external shell-hook protocol onto that same API. This plugin skips
8
+ * the translation: it runs `astrolabe hooks remind` on `tools/pre-execute` and
9
+ * maps the returned decision onto the harness's typed `PreToolDecision`.
10
+ *
11
+ * Astrolabe recognises its own MCP tools by substring, so a per-repository
12
+ * server name such as `mcp__astrolabe-dict__find_symbol` already resets the
13
+ * consecutive grep/read counter; no tool-name rewriting is needed here.
14
+ *
15
+ * @module astrolabe-dsh-guard
16
+ */
17
+
18
+ import { mkdirSync } from 'node:fs'
19
+ import { tmpdir } from 'node:os'
20
+ import { join } from 'node:path'
21
+
22
+ export const name = 'astrolabe-guard'
23
+
24
+ /** The command runner, the same seam the hook bridges use. */
25
+ export const inject = ['shell']
26
+
27
+ /**
28
+ * Tools whose calls change Astrolabe's drift counter.
29
+ *
30
+ * `grep`, `read`, `glob` and shell text search accumulate the counter; every
31
+ * Astrolabe tool call must also reach the hook so the counter resets — a denied
32
+ * grep otherwise stays denied until the idle timeout. Unrelated tools are
33
+ * skipped to avoid one process spawn per tool call.
34
+ */
35
+ const RELEVANT = /^(grep|read|glob|bash|shell|rg)$/i
36
+
37
+ /** Astrolabe tool names as they appear after an MCP server prefix. */
38
+ const ASTROLABE_TOOL =
39
+ /(?:^|__)(search_code|find_symbol|find_references|goto_definition|resolve_context|get_diagnostics|get_languages)$/
40
+
41
+ /**
42
+ * Whether this call can change Astrolabe's drift counter.
43
+ * @param toolName - the harness tool name, e.g. `grep` or `mcp__astrolabe-dict__search_code`.
44
+ * @returns true when the hook must observe the call.
45
+ */
46
+ function relevant(toolName) {
47
+ return RELEVANT.test(toolName) || (toolName.includes('astrolabe') && ASTROLABE_TOOL.test(toolName))
48
+ }
49
+
50
+ /**
51
+ * Build the `PreToolUse` payload `astrolabe hooks remind` parses.
52
+ * @param exec - the tool execution being gated.
53
+ * @returns the payload object.
54
+ */
55
+ function payloadOf(exec) {
56
+ return {
57
+ session_id: exec.agent?.session?.header?.id ?? '',
58
+ transcript_path: '',
59
+ cwd: exec.agent?.session?.header?.cwd ?? process.cwd(),
60
+ hook_event_name: 'PreToolUse',
61
+ tool_name: exec.name,
62
+ tool_input: exec.arguments,
63
+ tool_use_id: exec.callId,
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Run `astrolabe hooks remind` and decode its decision.
69
+ * @param shell - the harness shell executor.
70
+ * @param spec - binary, client dialect, timeout, state home and the payload to pipe in.
71
+ * @returns the decoded hook output, or undefined when it printed nothing usable.
72
+ */
73
+ async function remind(shell, spec) {
74
+ const request = {
75
+ command: `${spec.binary} hooks remind --client=${spec.client}`,
76
+ timeoutMs: spec.timeoutMs,
77
+ stdin: JSON.stringify(spec.payload),
78
+ // Astrolabe persists its consecutive-call counter under `$HOME/.astrolabe`.
79
+ // This command runs inside the harness file sandbox, where the user's home
80
+ // is readable but not writable, so the counter write would fail and the hook
81
+ // would silently fail open. A writable state home keeps the counter alive
82
+ // without widening the sandbox.
83
+ env: { HOME: spec.stateHome },
84
+ ...(spec.signal === undefined ? {} : { signal: spec.signal }),
85
+ }
86
+ const result = await (await shell.execute(shell.resolve(request))).result()
87
+ const text = (result.stdout?.text ?? '').trim()
88
+ if (text === '') return undefined
89
+ try {
90
+ return JSON.parse(text)
91
+ } catch {
92
+ // A malformed decision is not a blocking one: fail open.
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Install the guard.
99
+ * @param ctx - the plugin context, providing `shell` and the tool events.
100
+ * @param config - plugin configuration (all optional).
101
+ */
102
+ export function apply(ctx, config = {}) {
103
+ const binary = typeof config.binary === 'string' && config.binary !== '' ? config.binary : 'astrolabe'
104
+ const client = typeof config.client === 'string' && config.client !== '' ? config.client : 'claude-code'
105
+ const timeoutMs = Number.isFinite(config.timeoutMs) && config.timeoutMs > 0 ? config.timeoutMs : 20000
106
+ const stateHome = typeof config.stateDir === 'string' && config.stateDir !== ''
107
+ ? config.stateDir
108
+ : join(tmpdir(), 'astrolabe-dsh-guard')
109
+
110
+ try {
111
+ mkdirSync(stateHome, { recursive: true })
112
+ } catch {
113
+ // A state home we cannot create leaves the hook fail-open, never blocking.
114
+ }
115
+
116
+ ctx.on('tools/pre-execute', async (exec, next) => {
117
+ if (!relevant(exec.name)) return next()
118
+
119
+ let output
120
+ try {
121
+ output = await remind(ctx.shell, {
122
+ binary,
123
+ client,
124
+ timeoutMs,
125
+ stateHome,
126
+ payload: payloadOf(exec),
127
+ signal: exec.signal,
128
+ })
129
+ } catch {
130
+ // A hook that cannot run must never block the turn — same rule as the
131
+ // harness's own hook bridges.
132
+ return next()
133
+ }
134
+
135
+ const decision = output?.hookSpecificOutput
136
+ if (decision?.permissionDecision === 'deny') {
137
+ return {
138
+ kind: 'deny',
139
+ reason: decision.permissionDecisionReason
140
+ ?? 'Astrolabe: use the MCP code-navigation tools instead of grep/read',
141
+ }
142
+ }
143
+ return next()
144
+ })
145
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "astrolabe-dsh-guard",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Astrolabe drift guard for DeepSeek Harness: deny repeated grep/read calls and steer the model to Astrolabe's MCP code-navigation tools.",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib/index.js",
14
+ "cordis.patch.yml",
15
+ "README.md"
16
+ ],
17
+ "dsh": {
18
+ "bundle": {
19
+ "patch": "./cordis.patch.yml"
20
+ }
21
+ },
22
+ "keywords": [
23
+ "deepseek-harness",
24
+ "dsh",
25
+ "astrolabe",
26
+ "hook",
27
+ "mcp"
28
+ ],
29
+ "license": "MIT OR Apache-2.0",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/wojiushirencai/Astrolabe.git",
33
+ "directory": "dsh-plugin"
34
+ },
35
+ "engines": {
36
+ "node": ">=22"
37
+ }
38
+ }
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const REMIND_CMD = 'astrolabe hooks remind --client=zcode';
7
+
8
+ function detectZCode(home, env, { whichFn, existsFn }) {
9
+ const evidence = [];
10
+ if (whichFn('zcode', env)) evidence.push('zcode on PATH');
11
+ if (existsFn(path.join(home, '.zcode'))) evidence.push('~/.zcode/');
12
+ if (existsFn(path.join(home, '.zcode', 'cli'))) evidence.push('~/.zcode/cli/');
13
+ if (existsFn(path.join(home, '.zcode', 'cli', 'config.json'))) {
14
+ evidence.push('~/.zcode/cli/config.json');
15
+ }
16
+ return { detected: evidence.length > 0, evidence };
17
+ }
18
+
19
+ function resolveZcodeConfigPath(home, existsFn) {
20
+ // Prefer documented ~/.zcode/cli/config.json (HOOKS.md + z.ai docs).
21
+ // Some forks also use setting.json; only fall back when that alone exists.
22
+ const configJson = path.join(home, '.zcode', 'cli', 'config.json');
23
+ const settingJson = path.join(home, '.zcode', 'cli', 'setting.json');
24
+ if (existsFn(configJson)) return configJson;
25
+ if (existsFn(settingJson)) return settingJson;
26
+ return configJson;
27
+ }
28
+
29
+ function zcodeServerEntry(launch) {
30
+ return {
31
+ type: 'stdio',
32
+ command: launch.command,
33
+ args: launch.args,
34
+ };
35
+ }
36
+
37
+ function preToolUseEntry() {
38
+ return {
39
+ matcher: '*',
40
+ hooks: [
41
+ {
42
+ type: 'command',
43
+ command: REMIND_CMD,
44
+ timeout: 10,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+
50
+ function entryHasRemind(entry) {
51
+ if (!entry || typeof entry !== 'object') return false;
52
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
53
+ return hooks.some(
54
+ (h) =>
55
+ h &&
56
+ typeof h.command === 'string' &&
57
+ h.command.includes('astrolabe hooks remind --client=zcode'),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Merge mcp.servers.astrolabe and (optional) hooks.events.PreToolUse into
63
+ * ~/.zcode/cli/config.json. Never writes SessionEnd; never hangs cleanup on Stop.
64
+ */
65
+ function mergeZcodeConfigFile(
66
+ filePath,
67
+ launch,
68
+ { create = true, withHooks = false, existsFn = fs.existsSync } = {},
69
+ ) {
70
+ let created = false;
71
+ let doc = {};
72
+ if (existsFn(filePath)) {
73
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
74
+ doc = JSON.parse(raw);
75
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
76
+ throw new Error(`${filePath} root must be a JSON object`);
77
+ }
78
+ } else if (!create) {
79
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
80
+ } else {
81
+ created = true;
82
+ }
83
+
84
+ if (!doc.mcp || typeof doc.mcp !== 'object' || Array.isArray(doc.mcp)) {
85
+ doc.mcp = {};
86
+ }
87
+ if (!doc.mcp.servers || typeof doc.mcp.servers !== 'object' || Array.isArray(doc.mcp.servers)) {
88
+ doc.mcp.servers = {};
89
+ }
90
+ const hadServer = Object.prototype.hasOwnProperty.call(doc.mcp.servers, 'astrolabe');
91
+ doc.mcp.servers.astrolabe = zcodeServerEntry(launch);
92
+
93
+ let hooksReplaced = false;
94
+ if (withHooks) {
95
+ if (!doc.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
96
+ doc.hooks = {};
97
+ }
98
+ doc.hooks.enabled = true;
99
+ if (!doc.hooks.events || typeof doc.hooks.events !== 'object' || Array.isArray(doc.hooks.events)) {
100
+ doc.hooks.events = {};
101
+ }
102
+ // Hard rule: never SessionEnd (ZCode rejects unknown events).
103
+ if (Object.prototype.hasOwnProperty.call(doc.hooks.events, 'SessionEnd')) {
104
+ delete doc.hooks.events.SessionEnd;
105
+ }
106
+ if (!Array.isArray(doc.hooks.events.PreToolUse)) {
107
+ doc.hooks.events.PreToolUse = [];
108
+ }
109
+ const idx = doc.hooks.events.PreToolUse.findIndex(entryHasRemind);
110
+ const next = preToolUseEntry();
111
+ if (idx >= 0) {
112
+ doc.hooks.events.PreToolUse[idx] = next;
113
+ hooksReplaced = true;
114
+ } else {
115
+ doc.hooks.events.PreToolUse.push(next);
116
+ }
117
+ }
118
+
119
+ return {
120
+ filePath,
121
+ doc,
122
+ created,
123
+ replaced: hadServer || hooksReplaced,
124
+ entry: doc.mcp.servers.astrolabe,
125
+ withHooks,
126
+ };
127
+ }
128
+
129
+ module.exports = {
130
+ REMIND_CMD,
131
+ detectZCode,
132
+ resolveZcodeConfigPath,
133
+ zcodeServerEntry,
134
+ mergeZcodeConfigFile,
135
+ };