external-subagent 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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/external-subagent-debug-mcp.mjs +5 -0
  4. package/bin/external-subagent-debug.mjs +13 -0
  5. package/bin/external-subagent-mcp.mjs +20 -0
  6. package/bin/external-subagent.mjs +8 -0
  7. package/bin/zas.mjs +8 -0
  8. package/cli/commands/agents.mjs +117 -0
  9. package/cli/commands/config.mjs +117 -0
  10. package/cli/commands/daemon.mjs +36 -0
  11. package/cli/commands/maintenance.mjs +27 -0
  12. package/cli/commands/plugin.mjs +96 -0
  13. package/cli/commands/tasks.mjs +64 -0
  14. package/cli/commands/update.mjs +219 -0
  15. package/cli/config/read.mjs +24 -0
  16. package/cli/config/schema.mjs +75 -0
  17. package/cli/config/write.mjs +39 -0
  18. package/cli/constants.mjs +24 -0
  19. package/cli/errors.mjs +7 -0
  20. package/cli/fs-atomic.mjs +36 -0
  21. package/cli/install/codex.mjs +352 -0
  22. package/cli/install/init.mjs +206 -0
  23. package/cli/install/layout.mjs +54 -0
  24. package/cli/install/npm-hook.mjs +49 -0
  25. package/cli/install/path.mjs +43 -0
  26. package/cli/install/payload.mjs +84 -0
  27. package/cli/install/plugin-stage.mjs +232 -0
  28. package/cli/install/reconcile.mjs +209 -0
  29. package/cli/install/recovery.mjs +79 -0
  30. package/cli/install/service-activation.mjs +133 -0
  31. package/cli/install/service-macos.mjs +146 -0
  32. package/cli/install/update.mjs +170 -0
  33. package/cli/install/zcode.mjs +239 -0
  34. package/cli/main.mjs +429 -0
  35. package/cli/maintenance.mjs +104 -0
  36. package/cli/paths.mjs +40 -0
  37. package/cli/rpc.mjs +261 -0
  38. package/launchd/com.external-subagent.daemon.plist.template +37 -0
  39. package/npm/native/README.md +19 -0
  40. package/npm/native/darwin-arm64/external-subagent-mcp +0 -0
  41. package/npm/native/darwin-arm64/external-subagentd +0 -0
  42. package/npm/native/darwin-arm64/payload.json +20 -0
  43. package/package.json +60 -0
  44. package/plugins/codex/external-subagent/.codex-plugin/plugin.json +17 -0
  45. package/plugins/codex/external-subagent/.mcp.json +9 -0
  46. package/plugins/codex/external-subagent/hooks/audit-bash-result.mjs +55 -0
  47. package/plugins/codex/external-subagent/hooks/check-agent-files.mjs +19 -0
  48. package/plugins/codex/external-subagent/hooks/guard-bash.mjs +15 -0
  49. package/plugins/codex/external-subagent/lib/agent-file-policy.mjs +206 -0
  50. package/plugins/codex/external-subagent/scripts/install-agent-hooks.mjs +149 -0
  51. package/plugins/codex/external-subagent/scripts/mcp-stdio-bridge.mjs +36 -0
  52. package/plugins/codex/external-subagent/scripts/policy-verifier.mjs +44 -0
  53. package/plugins/codex/external-subagent/skills/external-subagent/SKILL.md +55 -0
  54. package/schema/external-subagent-public-api.json +185 -0
  55. package/schema/zas-observation-v1.1.schema.json +58 -0
  56. package/schema/zas-public-reasoning-source.json +14 -0
  57. package/scripts/release/build-native-payload.mjs +119 -0
  58. package/scripts/release/check-native-tarball.mjs +125 -0
  59. package/scripts/release/stage-debug-plugin.mjs +60 -0
  60. package/scripts/release/test-installed-tarball.mjs +73 -0
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ let raw = '';
3
+ process.stdin.setEncoding('utf8');
4
+ for await (const chunk of process.stdin) raw += chunk;
5
+ try {
6
+ const input = JSON.parse(raw);
7
+ const managed = process.env.ZCODE_AGENT_POLICY === '1';
8
+ const mode = process.env.ZCODE_AGENT_PERMISSION_MODE || process.env.ZCODE_PERMISSION_MODE;
9
+ if (!managed || mode !== 'plan' || (input?.tool_name ?? input?.toolName) !== 'Bash') process.exit(0);
10
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'external-subagent policy: Bash is disabled in plan probes' } })}\n`);
11
+ } catch {
12
+ if (process.env.ZCODE_AGENT_POLICY === '1') {
13
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'external-subagent policy: malformed hook input' } })}\n`);
14
+ }
15
+ }
@@ -0,0 +1,206 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const AGENT_FILE_POLICY_VERSION = 'zcode-agent-file-policy/v1.0.0';
5
+ const MAX_MANIFEST_BYTES = 64 * 1024;
6
+ const MAX_MANIFEST_ENTRIES = 256;
7
+ const MAX_BOOTSTRAP_ROOTS = 8;
8
+ const MAX_PATH_BYTES = 4096;
9
+ const WRITE_TOOLS = new Set(['Write', 'Edit', 'Delete', 'Move']);
10
+ const READ_TOOLS = new Set(['Read', 'Grep', 'Glob']);
11
+ const PATH_KEYS = new Set([
12
+ 'path', 'file_path', 'filePath', 'directory', 'directory_path', 'directoryPath',
13
+ 'source', 'source_path', 'sourcePath', 'destination', 'destination_path', 'destinationPath',
14
+ 'old_path', 'oldPath', 'new_path', 'newPath', 'from', 'to',
15
+ ]);
16
+ const PROTECTED_PART = /^(?:\.git|\.gitmodules|\.zcode|\.codex|\.agent-work)$/u;
17
+
18
+ function deny(code) {
19
+ return { decision: 'deny', code, reason: `${AGENT_FILE_POLICY_VERSION}: ${code}` };
20
+ }
21
+
22
+ function allow() {
23
+ return { decision: 'allow', code: 'ok', reason: `${AGENT_FILE_POLICY_VERSION}: allowed` };
24
+ }
25
+
26
+ function ask() {
27
+ return { decision: 'ask', code: 'edit_requires_approval', reason: `${AGENT_FILE_POLICY_VERSION}: edit requires external approval` };
28
+ }
29
+
30
+ function envRequired(env) {
31
+ if (env?.ZCODE_AGENT_POLICY !== '1') return { decision: 'skip', code: 'policy_not_managed', reason: `${AGENT_FILE_POLICY_VERSION}: unmanaged session` };
32
+ const rawRoot = env?.ZCODE_AGENT_WORKSPACE_ROOT;
33
+ if (typeof rawRoot !== 'string' || rawRoot.length === 0 || !path.isAbsolute(rawRoot)) {
34
+ return deny('workspace_root_missing');
35
+ }
36
+ let root;
37
+ try {
38
+ root = fs.realpathSync.native(rawRoot);
39
+ if (!fs.statSync(root).isDirectory()) return deny('workspace_root_invalid');
40
+ } catch {
41
+ return deny('workspace_root_invalid');
42
+ }
43
+ let manifest = [];
44
+ const rawManifest = env?.ZCODE_AGENT_WRITE_MANIFEST;
45
+ if (typeof rawManifest !== 'string' || Buffer.byteLength(rawManifest, 'utf8') > MAX_MANIFEST_BYTES) {
46
+ return deny('write_manifest_invalid');
47
+ }
48
+ try {
49
+ const parsed = rawManifest.length === 0 ? [] : JSON.parse(rawManifest);
50
+ if (!Array.isArray(parsed) || parsed.length > MAX_MANIFEST_ENTRIES) return deny('write_manifest_invalid');
51
+ manifest = parsed.map((item) => {
52
+ if (typeof item !== 'string' || item.length === 0 || Buffer.byteLength(item, 'utf8') > MAX_PATH_BYTES) {
53
+ throw new Error('manifest path');
54
+ }
55
+ const candidate = path.posix.normalize(item.replaceAll('\\', '/'));
56
+ if (path.posix.isAbsolute(candidate) || candidate === '..' || candidate.startsWith('../') || candidate.includes('\0')) {
57
+ throw new Error('manifest traversal');
58
+ }
59
+ if (candidate.split('/').some((part) => PROTECTED_PART.test(part))) {
60
+ throw new Error('manifest protected');
61
+ }
62
+ return candidate === '.' ? '' : candidate;
63
+ });
64
+ } catch {
65
+ return deny('write_manifest_invalid');
66
+ }
67
+ let bootstrapRoots = [];
68
+ const rawBootstrapRoots = env?.ZCODE_AGENT_BOOTSTRAP_ROOTS;
69
+ if (rawBootstrapRoots !== undefined) {
70
+ if (typeof rawBootstrapRoots !== 'string' || Buffer.byteLength(rawBootstrapRoots, 'utf8') > MAX_MANIFEST_BYTES) {
71
+ return deny('bootstrap_roots_invalid');
72
+ }
73
+ try {
74
+ const parsed = rawBootstrapRoots.trimStart().startsWith('[')
75
+ ? JSON.parse(rawBootstrapRoots)
76
+ : [rawBootstrapRoots];
77
+ if (!Array.isArray(parsed) || parsed.length > MAX_BOOTSTRAP_ROOTS) throw new Error('bootstrap roots');
78
+ bootstrapRoots = parsed.map((item) => {
79
+ if (typeof item !== 'string' || !path.isAbsolute(item) || item.length > MAX_PATH_BYTES || item.includes('\0') || hasParentTraversal(item)) {
80
+ throw new Error('bootstrap root path');
81
+ }
82
+ if (item.split(/[\\/]/u).some((part) => PROTECTED_PART.test(part))) {
83
+ throw new Error('bootstrap protected');
84
+ }
85
+ try {
86
+ if (!fs.statSync(item).isDirectory()) throw new Error('bootstrap not directory');
87
+ return fs.realpathSync.native(item);
88
+ } catch {
89
+ // A missing optional bootstrap root is inert; reads under it remain denied.
90
+ return item;
91
+ }
92
+ });
93
+ } catch {
94
+ return deny('bootstrap_roots_invalid');
95
+ }
96
+ }
97
+ return { root, manifest, bootstrapRoots };
98
+ }
99
+
100
+ function collectPaths(value, key = '') {
101
+ if (typeof value === 'string') return PATH_KEYS.has(key) ? [value] : [];
102
+ if (!value || typeof value !== 'object') return [];
103
+ if (Array.isArray(value)) return value.flatMap((item) => collectPaths(item, key));
104
+ return Object.entries(value).flatMap(([childKey, child]) => collectPaths(child, childKey));
105
+ }
106
+
107
+ function hasParentTraversal(value) {
108
+ return value.split(/[\\/]/u).some((part) => part === '..');
109
+ }
110
+
111
+ function confinedPath(root, value) {
112
+ if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES) {
113
+ return false;
114
+ }
115
+ if (value.includes('\0') || hasParentTraversal(value)) return false;
116
+ const canonicalCandidate = resolveCanonicalPath(root, value);
117
+ if (!canonicalCandidate) return false;
118
+ return canonicalCandidate === root || canonicalCandidate.startsWith(`${root}${path.sep}`);
119
+ }
120
+
121
+ function resolveCanonicalPath(root, value) {
122
+ if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES) {
123
+ return null;
124
+ }
125
+ if (value.includes('\0') || hasParentTraversal(value)) return null;
126
+ const candidate = path.isAbsolute(value) ? value : path.join(root, value);
127
+ const lexical = path.normalize(candidate);
128
+ let existing = lexical;
129
+ const missing = [];
130
+ while (!fs.existsSync(existing)) {
131
+ const parent = path.dirname(existing);
132
+ if (parent === existing) return null;
133
+ missing.unshift(path.basename(existing));
134
+ existing = parent;
135
+ }
136
+ try {
137
+ const canonical = fs.realpathSync.native(existing);
138
+ return missing.length > 0 ? path.join(canonical, ...missing) : canonical;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function protectedPath(root, value) {
145
+ const relative = path.isAbsolute(value) ? path.relative(root, value) : value;
146
+ const lexicalProtected = relative.split(/[\\/]/u).some((part) => PROTECTED_PART.test(part));
147
+ if (lexicalProtected) return true;
148
+ const canonical = resolveCanonicalPath(root, value);
149
+ if (!canonical) return false;
150
+ return path.relative(root, canonical)
151
+ .split(/[\\/]/u)
152
+ .some((part) => PROTECTED_PART.test(part));
153
+ }
154
+
155
+ function pathAllowedForRead(state, value) {
156
+ // Relative paths are daemon-worktree paths. Never reinterpret a relative
157
+ // payload against a bootstrap root (that would turn a worktree symlink into
158
+ // an apparently safe bootstrap path). Bootstrap roots are absolute-only.
159
+ if (!path.isAbsolute(value)) {
160
+ return confinedPath(state.root, value) && !protectedPath(state.root, value);
161
+ }
162
+ return [state.root, ...state.bootstrapRoots].some(
163
+ (root) => confinedPath(root, value) && !protectedPath(root, value),
164
+ );
165
+ }
166
+
167
+ function withinManifest(root, value, manifest) {
168
+ const candidate = resolveCanonicalPath(root, value);
169
+ if (!candidate) return false;
170
+ return manifest.some((entry) => {
171
+ const target = resolveCanonicalPath(root, entry);
172
+ if (!target) return false;
173
+ return candidate === target || candidate.startsWith(`${target}${path.sep}`);
174
+ });
175
+ }
176
+
177
+ export function evaluateAgentFileInput(input, env = process.env) {
178
+ const state = envRequired(env);
179
+ if (state.decision) return state;
180
+ const tool = input?.tool_name ?? input?.toolName ?? input?.name;
181
+ if (typeof tool !== 'string' || (!READ_TOOLS.has(tool) && !WRITE_TOOLS.has(tool))) {
182
+ return deny('unsupported_tool');
183
+ }
184
+ const toolInput = input?.tool_input ?? input?.toolInput ?? input?.input ?? {};
185
+ const paths = collectPaths(toolInput);
186
+ // An omitted path is valid for a search rooted at cwd, but a file mutation must name a target.
187
+ if (WRITE_TOOLS.has(tool) && paths.length === 0) return deny('path_missing');
188
+ const cwd = input?.cwd ?? input?.working_directory ?? input?.workingDirectory ?? state.root;
189
+ if (!confinedPath(state.root, cwd) || protectedPath(state.root, cwd)) return deny('cwd_outside_root');
190
+ for (const value of paths.length ? paths : [cwd]) {
191
+ if (!pathAllowedForRead(state, value)) return deny('path_outside_root');
192
+ if (WRITE_TOOLS.has(tool) && !withinManifest(state.root, value, state.manifest)) return deny('write_not_allowlisted');
193
+ }
194
+ if (WRITE_TOOLS.has(tool) && env.ZCODE_AGENT_PERMISSION_MODE === 'edit') return ask();
195
+ return allow();
196
+ }
197
+
198
+ export function createAgentFileHookOutput(result) {
199
+ return {
200
+ hookSpecificOutput: {
201
+ hookEventName: 'PreToolUse',
202
+ permissionDecision: result.decision,
203
+ permissionDecisionReason: result.reason,
204
+ },
205
+ };
206
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ const pluginRoot = path.resolve(new URL('..', import.meta.url).pathname);
7
+ const hookRoot = pluginRoot;
8
+ const configPath = process.argv[process.argv.indexOf('--config') + 1];
9
+ if (!configPath || configPath.startsWith('--')) {
10
+ console.error('usage: node install-agent-hooks.mjs --config /absolute/config.json [--provenance /absolute/provenance.json]');
11
+ process.exit(2);
12
+ }
13
+ const provenanceIndex = process.argv.indexOf('--provenance');
14
+ const provenancePath = provenanceIndex >= 0
15
+ ? process.argv[provenanceIndex + 1]
16
+ : path.join(path.dirname(configPath), 'zcode-agent-hook-provenance.json');
17
+
18
+ function readJson(file, fallback) {
19
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) {
20
+ if (error?.code === 'ENOENT') return fallback;
21
+ throw error;
22
+ }
23
+ }
24
+
25
+ function atomicWriteBytes(file, bytes) {
26
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
27
+ const temporary = `${file}.tmp-${process.pid}`;
28
+ try {
29
+ fs.writeFileSync(temporary, bytes, { mode: 0o600 });
30
+ fs.renameSync(temporary, file);
31
+ } finally {
32
+ try { fs.unlinkSync(temporary); } catch (error) {
33
+ if (error?.code !== 'ENOENT') throw error;
34
+ }
35
+ }
36
+ }
37
+
38
+ const encodeJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
39
+ const atomicWrite = (file, value) => atomicWriteBytes(file, encodeJson(value));
40
+
41
+ const config = readJson(configPath, {});
42
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('config must be a JSON object');
43
+ if (fs.existsSync(provenancePath)) {
44
+ const prior = readJson(provenancePath, null);
45
+ if (!prior || prior.effective_config_path !== path.resolve(configPath)) {
46
+ throw new Error(`HOOK_PROVENANCE_INVALID: effective_config_path does not match ${path.resolve(configPath)}`);
47
+ }
48
+ }
49
+ const next = structuredClone(config);
50
+ next.hooks ??= {};
51
+ next.hooks.enabled = true;
52
+ next.hooks.events ??= {};
53
+ const hashFile = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
54
+ // Resolve every shipped decision owner before mutating the caller's config.
55
+ // A copied or incomplete plugin must fail without leaving a partial install.
56
+ const effectiveConfigPath = path.resolve(configPath);
57
+ const effectiveFilePolicyPath = path.join(hookRoot, 'lib', 'agent-file-policy.mjs');
58
+ const auditWrapperPath = path.join(hookRoot, 'hooks', 'audit-bash-result.mjs');
59
+ const fileWrapperPath = path.join(hookRoot, 'hooks', 'check-agent-files.mjs');
60
+ const guardWrapperPath = path.join(hookRoot, 'hooks', 'guard-bash.mjs');
61
+ const verifierSourcePath = path.join(pluginRoot, 'scripts', 'policy-verifier.mjs');
62
+ const filePolicySha256 = hashFile(effectiveFilePolicyPath);
63
+ if (effectiveConfigPath === path.resolve(provenancePath)) throw new Error('config and provenance paths must differ');
64
+ const events = {
65
+ PreToolUse: [
66
+ { matcher: 'Bash', script: 'hooks/guard-bash.mjs' },
67
+ { matcher: '^(Read|Grep|Glob|Write|Edit|Delete|Move)$', script: 'hooks/check-agent-files.mjs' },
68
+ ],
69
+ PostToolUse: [{ matcher: 'Bash', script: 'hooks/audit-bash-result.mjs' }],
70
+ PostToolUseFailure: [{ matcher: 'Bash', script: 'hooks/audit-bash-result.mjs' }],
71
+ };
72
+
73
+ function processHookArgs(candidate) {
74
+ if (!candidate || !Array.isArray(candidate.hooks)) return [];
75
+ return candidate.hooks
76
+ .filter((hook) => hook?.type === 'process' && Array.isArray(hook.args))
77
+ .flatMap((hook) => hook.args)
78
+ .filter((arg) => typeof arg === 'string');
79
+ }
80
+
81
+ function isRecognizedAgentHook(candidate, matcher, expectedScript) {
82
+ if (candidate?.matcher !== matcher) return false;
83
+ const args = processHookArgs(candidate);
84
+ return args.some((arg) => path.resolve(arg) === expectedScript);
85
+ }
86
+
87
+ for (const [event, expectedEntries] of Object.entries(events)) {
88
+ const existing = Array.isArray(next.hooks.events[event]) ? next.hooks.events[event] : [];
89
+ for (const { matcher, script } of expectedEntries) {
90
+ const expectedScript = path.join(hookRoot, script);
91
+ const managedEntries = existing.filter((candidate) => candidate?.matcher === matcher);
92
+ const unknownEntries = managedEntries.filter((candidate) => !isRecognizedAgentHook(candidate, matcher, expectedScript));
93
+ if (unknownEntries.length > 0) {
94
+ throw new Error(`${event} contains an unknown managed hook; refusing to modify configuration`);
95
+ }
96
+ }
97
+ const unrelated = existing.filter((candidate) => !expectedEntries.some(({ matcher }) => candidate?.matcher === matcher));
98
+ next.hooks.events[event] = [
99
+ ...unrelated,
100
+ ...expectedEntries.map(({ matcher, script }) => ({
101
+ matcher,
102
+ hooks: [{ type: 'process', command: process.execPath, args: [path.join(hookRoot, script)], timeoutMs: 5000 }],
103
+ })),
104
+ ];
105
+ }
106
+ const nextConfigBytes = encodeJson(next);
107
+ const nextProvenance = {
108
+ effective_file_policy_version: 'zcode-agent-file-policy/v1.0.0',
109
+ effective_file_policy_sha256: filePolicySha256,
110
+ effective_file_policy_path: effectiveFilePolicyPath,
111
+ effective_guard_wrapper_path: guardWrapperPath,
112
+ effective_guard_wrapper_sha256: hashFile(guardWrapperPath),
113
+ effective_config_path: effectiveConfigPath,
114
+ effective_config_sha256: crypto.createHash('sha256').update(nextConfigBytes).digest('hex'),
115
+ effective_audit_wrapper_path: auditWrapperPath,
116
+ effective_audit_wrapper_sha256: hashFile(auditWrapperPath),
117
+ effective_file_wrapper_path: fileWrapperPath,
118
+ effective_file_wrapper_sha256: hashFile(fileWrapperPath),
119
+ hook_activation_verified: true,
120
+ activation_method: 'outer-plugin-install',
121
+ activation_generation: `${Date.now()}-${filePolicySha256.slice(0, 12)}`,
122
+ };
123
+ const previousProvenance = fs.existsSync(provenancePath) ? fs.readFileSync(provenancePath) : null;
124
+ const verifierPath = path.join(path.dirname(provenancePath), 'external-subagent-policy-verifier');
125
+ const previousVerifier = fs.existsSync(verifierPath)
126
+ ? { bytes: fs.readFileSync(verifierPath), mode: fs.statSync(verifierPath).mode & 0o777 }
127
+ : null;
128
+ try {
129
+ atomicWrite(provenancePath, nextProvenance);
130
+ atomicWriteBytes(verifierPath, fs.readFileSync(verifierSourcePath));
131
+ fs.chmodSync(verifierPath, 0o700);
132
+ atomicWriteBytes(configPath, nextConfigBytes);
133
+ } catch (error) {
134
+ if (previousProvenance === null) {
135
+ try { fs.unlinkSync(provenancePath); } catch (unlinkError) {
136
+ if (unlinkError?.code !== 'ENOENT') throw unlinkError;
137
+ }
138
+ } else {
139
+ atomicWriteBytes(provenancePath, previousProvenance);
140
+ }
141
+ if (previousVerifier === null) {
142
+ try { fs.unlinkSync(verifierPath); } catch (unlinkError) { if (unlinkError?.code !== 'ENOENT') throw unlinkError; }
143
+ } else {
144
+ atomicWriteBytes(verifierPath, previousVerifier.bytes);
145
+ fs.chmodSync(verifierPath, previousVerifier.mode);
146
+ }
147
+ throw error;
148
+ }
149
+ console.log(JSON.stringify({ config: effectiveConfigPath, provenance: path.resolve(provenancePath), file_policy_sha256: filePolicySha256 }));
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // Stateless stdio bridge for the shared daemon MCP listener, in node.
3
+ //
4
+ // Mirrors crates/external-mcp (the native facade) byte for byte in
5
+ // behavior: resolve the daemon MCP stream endpoint from
6
+ // ZCODE_AGENTD_SOCKET, connect, and pipe client stdio <-> socket until
7
+ // either side reaches EOF. Hosts that spawn MCP servers inside
8
+ // restricted execution contexts (ZCode kills ad-hoc-signed native
9
+ // binaries there, while node processes and unix sockets are allowed)
10
+ // bind this script through the installing node instead of the native
11
+ // binary; see docs/compatibility/zcode.md.
12
+ import net from 'node:net';
13
+ import path from 'node:path';
14
+
15
+ const socket = process.env.ZCODE_AGENTD_SOCKET;
16
+ if (!socket || !path.isAbsolute(socket)) {
17
+ console.error('mcp-stdio-bridge: ZCODE_AGENTD_SOCKET must be an absolute path');
18
+ process.exit(1);
19
+ }
20
+ // The stable daemon RPC socket keeps its name; MCP is exposed by the
21
+ // sibling `.mcp` stream endpoint, except in isolated tests and
22
+ // non-standard deployments that point at the MCP endpoint directly.
23
+ const endpoint = path.basename(socket) === 'external-subagent.sock'
24
+ ? socket.replace(/\.sock$/u, '.mcp')
25
+ : socket;
26
+
27
+ const stream = net.createConnection(endpoint);
28
+ stream.on('error', (error) => {
29
+ console.error(`mcp-stdio-bridge: failed to connect to daemon MCP socket ${endpoint}: ${error.message}`);
30
+ process.exit(1);
31
+ });
32
+ stream.on('close', () => process.exit(0));
33
+ process.stdin.on('end', () => process.exit(0));
34
+ process.stdin.on('error', () => process.exit(0));
35
+ process.stdin.pipe(stream);
36
+ stream.pipe(process.stdout);
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ function value(name) {
7
+ const index = process.argv.indexOf(name);
8
+ const result = index >= 0 ? process.argv[index + 1] : undefined;
9
+ if (!result || result.startsWith('--')) process.exit(2);
10
+ return result;
11
+ }
12
+
13
+ const workspace = path.resolve(value('--workspace'));
14
+ const home = path.resolve(value('--home'));
15
+ const mode = value('--permission-mode');
16
+ const manifest = value('--write-manifest');
17
+ if (mode !== 'plan' || manifest !== '[]' || !fs.statSync(workspace).isDirectory()) process.exit(2);
18
+ const configPath = path.join(home, '.zcode', 'cli', 'config.json');
19
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
20
+ const events = config?.hooks?.events;
21
+ if (config?.hooks?.enabled !== true || !events || !Array.isArray(events.PreToolUse)) process.exit(2);
22
+ const pre = events.PreToolUse.find((entry) => entry?.matcher === '^(Read|Grep|Glob|Write|Edit|Delete|Move)$');
23
+ const hook = pre?.hooks?.find((entry) => entry?.type === 'process' && Array.isArray(entry.args));
24
+ const script = hook?.args?.find((candidate) => typeof candidate === 'string' && candidate.endsWith('/hooks/check-agent-files.mjs'));
25
+ const guard = events.PreToolUse.find((entry) => entry?.matcher === 'Bash')?.hooks?.find((entry) => entry?.type === 'process')?.args?.[0];
26
+ const audit = events.PostToolUse?.find((entry) => entry?.matcher === 'Bash')?.hooks?.find((entry) => entry?.type === 'process')?.args?.[0];
27
+ if (!script || !fs.statSync(script).isFile() || hook.command !== process.execPath || !guard || !fs.statSync(guard).isFile() || !audit || !fs.statSync(audit).isFile()) process.exit(2);
28
+ const provenancePath = path.join(home, 'Library', 'Application Support', 'external-subagent', 'zcode-agent-hook-provenance.json');
29
+ if (!fs.existsSync(provenancePath)) process.exit(2);
30
+ const provenance = JSON.parse(fs.readFileSync(provenancePath, 'utf8'));
31
+ const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
32
+ const policy = path.resolve(path.dirname(script), '../lib/agent-file-policy.mjs');
33
+ if (provenance.effective_config_path !== path.resolve(configPath)
34
+ || provenance.hook_activation_verified !== true
35
+ || provenance.effective_file_policy_version !== 'zcode-agent-file-policy/v1.0.0'
36
+ || provenance.effective_file_policy_path !== policy
37
+ || provenance.effective_file_policy_sha256 !== hash(policy)
38
+ || provenance.effective_guard_wrapper_path !== path.resolve(guard)
39
+ || provenance.effective_guard_wrapper_sha256 !== hash(guard)
40
+ || provenance.effective_file_wrapper_path !== path.resolve(script)
41
+ || provenance.effective_file_wrapper_sha256 !== hash(script)
42
+ || provenance.effective_audit_wrapper_path !== path.resolve(audit)
43
+ || provenance.effective_audit_wrapper_sha256 !== hash(audit)) process.exit(2);
44
+ process.exit(0);
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: external-subagent
3
+ description: Manage bounded repository-scoped tasks through external-subagent.
4
+ ---
5
+
6
+ # external-subagent
7
+
8
+ Use the `external_subagent_*` tools to inspect status, spawn a task with an explicit
9
+ `subagent`, wait for progress, retrieve the complete result, and close the task.
10
+ Execution targets are `zcode`, `dsh`, and `codex`; each name currently has one
11
+ runtime instance and no instance selector is supported. Omitting `subagent` uses
12
+ `default_subagent` or returns `subagent_required`; unknown targets return
13
+ `subagent_unknown`, and unknown spawn fields are rejected before dispatch.
14
+
15
+ Spawn is intentionally non-idempotent: every successful call creates a fresh task
16
+ and returns `{agent_id, status}` (a duplicate workspace or id is a `conflict`
17
+ error, never reuse of an existing task). `status` carries only routing,
18
+ capability, and readiness facts (`subagents[]` with `configured`, `enabled`,
19
+ `spawn_supported`, permission modes, model selection, and per-scope states);
20
+ deployment identity, config revisions, adapter transport detail, and probe
21
+ evidence are operator diagnostics — read them with the CLI `diagnose` command
22
+ instead of the MCP status tool.
23
+
24
+ Spawn accepts an optional `effort` token that steers the task's reasoning
25
+ effort. Omit it to keep each agent's current default (codex keeps its existing
26
+ wire default; zcode and dsh send no effort field or call at all). `codex`
27
+ admits only the closed set
28
+ `low | medium | high | xhigh` — `minimal` and `max` are rejected;
29
+ `zcode` and `dsh` accept any bounded token (1..24 bytes of `[a-z0-9_]`) and
30
+ pass it through to the runtime, because the supported set is only known at
31
+ runtime. A malformed or unsupported token is rejected before dispatch, so no
32
+ task is created. Example: spawn with
33
+ `{"subagent": "zcode", "repository": "/absolute/path", "prompt": "...", "effort": "high"}`.
34
+
35
+ The caller is a `host`. Any unregistered local MCP client can act as `custom` and
36
+ call status or spawn without a host record or Codex home. Built-in `host.codex`
37
+ exists for installation and automatic-upgrade coordination, supports multiple
38
+ Codex home instances, and offers plugin or direct MCP bindings; built-in
39
+ `host.zcode` binds this same plugin through the ZCode config's inline plugin
40
+ dirs with a single per-user instance. This is separate
41
+ from `subagent.codex`. Protocol implementations are adapters.
42
+
43
+ Actual per-subagent limitations (from the accepted code):
44
+
45
+ - `zcode` — enabled and spawn-supported by default; all four permission modes
46
+ (build/edit/plan/yolo); an explicit spawn `model` is rejected
47
+ (`model_selection_unsupported`).
48
+ - `dsh` — disabled until explicitly enabled and configured
49
+ (`runtime_path`/`home`/`profile`/`version`); admits only `build` and strict
50
+ `plan`.
51
+ - `codex` — disabled by default; `plan` (sandbox=read-only) and `yolo`
52
+ (sandbox=danger-full-access), both pinning approvalPolicy=never;
53
+ `build`/`edit` are refused before the prompt.
54
+ - `observe` — verified public reasoning exists only for zcode tasks; observe
55
+ on a non-zcode task truthfully errors `unavailable`.