@elinpf/dsh-ops-shell-tool 0.2.1 → 0.4.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/README.md +2 -0
- package/lib/index.d.ts +33 -1
- package/lib/index.js +32 -5
- package/lib/types.d.ts +7 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,6 +12,8 @@ A pure library (not a plugin) — the single home for the boilerplate every ops
|
|
|
12
12
|
- **Honest kill reporting** — 30 s default timeout; a signal death normalizes to `exitCode: -1` with the cause (timeout / caller abort / signal name) spelled out in the `error` field, never a bare -1.
|
|
13
13
|
- **Environment-failure translation** — when stderr matches a known local-environment signature (e.g. ssh's startup `Couldn't open /dev/null` — the sandbox/host made /dev/null unwritable, so the command never left the machine), the `error` field carries the diagnosis and remediation instead of letting the model suspect credentials/network/remote.
|
|
14
14
|
- **stderr noise filtering** — consumer-declared regexes drop known-noise stderr lines (e.g. ceph keyring chatter) after scrubbing.
|
|
15
|
+
- **Per-call tier declaration** — every profiled tool carries an optional `tier: 'ro'|'rw'` parameter: omit = decided by the grant (rw when granted); `'ro'` = deliberate downgrade under an rw grant (declare it for pure queries); `'rw'` = require the write tier, failing loudly with request_access guidance when the session holds no grant. Forwarded to `opsAccess.resolve` as `AccessRequest`.
|
|
16
|
+
- **Session sandbox policy passthrough** — when the mounted shell executor confines (`ctx.shell.sandboxMode` defined), the factory resolves the CALLING session's policy via `ctx.get('sandboxPolicy')` (agent's `session` forwarded) and attaches it to the shell request — mirroring tool-bash. Without it the executor falls back to the deployment policy, whose workspace root is the dsh process cwd: for a systemd service that is `/`, so bwrap bind-mounts `/` over its own `/dev` tmpfs (ssh dies with `Couldn't open /dev/null`) and workspace-write silently degrades into the whole container root being writable. A confining executor without the sandboxPolicy service fails loudly instead of running under the wrong root.
|
|
15
17
|
- **`shellQuote`** — exported for consumers that must embed a whole remote command as one argument (ops-tool-ssh).
|
|
16
18
|
|
|
17
19
|
## Design notes
|
package/lib/index.d.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* @module @elinpf/dsh-ops-shell-tool
|
|
28
28
|
*/
|
|
29
29
|
import type { Context } from '@deepseek-ai/cordis';
|
|
30
|
-
import type { ProfiledShellToolSpec } from './types.js';
|
|
30
|
+
import type { ProfiledShellToolSpec, ShellToolResult } from './types.js';
|
|
31
31
|
export type { CredentialRef, ProfiledShellToolSpec, ShellToolExec, ShellToolResult } from './types.js';
|
|
32
32
|
/**
|
|
33
33
|
* Single-quote a value for safe shell embedding. Used for ref-token
|
|
@@ -43,6 +43,38 @@ export declare function shellQuote(value: string): string;
|
|
|
43
43
|
* instead of its own command shape.
|
|
44
44
|
*/
|
|
45
45
|
export declare function shellCompositionError(toolName: string, command: string): string | undefined;
|
|
46
|
+
/** The shared output contract: schema + render, both pure. Exported so non-shell tools (e.g. ops-tool-prometheus, which speaks HTTP but keeps the suite-standard result shape) can reuse it instead of copying. */
|
|
47
|
+
export declare const shellToolOutput: {
|
|
48
|
+
readonly schema: {
|
|
49
|
+
readonly type: "object";
|
|
50
|
+
readonly additionalProperties: false;
|
|
51
|
+
readonly properties: {
|
|
52
|
+
readonly exitCode: {
|
|
53
|
+
readonly type: "number";
|
|
54
|
+
readonly required: true;
|
|
55
|
+
};
|
|
56
|
+
readonly stdout: {
|
|
57
|
+
readonly type: "string";
|
|
58
|
+
readonly required: true;
|
|
59
|
+
};
|
|
60
|
+
readonly stderr: {
|
|
61
|
+
readonly type: "string";
|
|
62
|
+
readonly required: true;
|
|
63
|
+
};
|
|
64
|
+
readonly command: {
|
|
65
|
+
readonly type: "string";
|
|
66
|
+
readonly required: true;
|
|
67
|
+
};
|
|
68
|
+
readonly error: {
|
|
69
|
+
readonly type: "string";
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
readonly render: (_args: unknown, value: ShellToolResult) => {
|
|
74
|
+
type: "text";
|
|
75
|
+
text: string;
|
|
76
|
+
}[];
|
|
77
|
+
};
|
|
46
78
|
/**
|
|
47
79
|
* Register a profiled shell tool on `ctx.tools`, disposed with the plugin's
|
|
48
80
|
* fiber. The caller's plugin must declare `inject = ['shell', 'tools']`.
|
package/lib/index.js
CHANGED
|
@@ -124,8 +124,8 @@ export function shellCompositionError(toolName, command) {
|
|
|
124
124
|
const op = m[0] === '\n' || m[0] === '\r' ? 'a newline' : `'${m[0]}'`;
|
|
125
125
|
return `the command contains ${op} — everything after it would run as a NEW local command WITHOUT the ${toolName} prefix and injected credentials, failing with a misleading 'xxx: command not found'. One call = one ${toolName} command: split this into separate tool calls. (A single | pipe is allowed — it filters the output locally.)`;
|
|
126
126
|
}
|
|
127
|
-
/** The shared output contract: schema + render, both pure. */
|
|
128
|
-
const
|
|
127
|
+
/** The shared output contract: schema + render, both pure. Exported so non-shell tools (e.g. ops-tool-prometheus, which speaks HTTP but keeps the suite-standard result shape) can reuse it instead of copying. */
|
|
128
|
+
export const shellToolOutput = {
|
|
129
129
|
schema: {
|
|
130
130
|
type: 'object',
|
|
131
131
|
additionalProperties: false,
|
|
@@ -166,11 +166,16 @@ export function registerProfiledShellTool(ctx, spec) {
|
|
|
166
166
|
parameters: {
|
|
167
167
|
[spec.targetParam]: { type: 'string', required: true, description: spec.targetParamDescription },
|
|
168
168
|
command: { type: 'string', required: true, description: spec.commandDescription },
|
|
169
|
+
tier: {
|
|
170
|
+
type: 'string',
|
|
171
|
+
enum: ['ro', 'rw'],
|
|
172
|
+
description: 'Credential tier for THIS call. Omit = decided by the grant (rw when granted, else ro). "ro" = deliberate downgrade: use the read-only credential even while holding an rw grant — declare it for pure queries so you always know which power you are exercising. "rw" = require the write tier; without a session grant this fails loudly and points at request_access instead of silently reading.',
|
|
173
|
+
},
|
|
169
174
|
...(spec.perCallTimeout
|
|
170
175
|
? { timeoutSec: { type: 'number', description: `Optional per-call timeout in seconds (default ${Math.round((spec.timeoutMs ?? 30000) / 1000)}, max 600). Use only for a command you KNOW is slow (e.g. listing a very large pool) — a longer wait does not fix a hung remote end.` } }
|
|
171
176
|
: {}),
|
|
172
177
|
},
|
|
173
|
-
output,
|
|
178
|
+
output: shellToolOutput,
|
|
174
179
|
async execute(args, exec) {
|
|
175
180
|
let fullCommand = '';
|
|
176
181
|
try {
|
|
@@ -204,13 +209,35 @@ export function registerProfiledShellTool(ctx, spec) {
|
|
|
204
209
|
}
|
|
205
210
|
// Pass the caller agent through so the access gate (if mounted) can
|
|
206
211
|
// key grants on the session id. Without a gate this arg is inert.
|
|
207
|
-
|
|
212
|
+
// An explicit tier arg is the per-call declaration: 'ro' downgrades
|
|
213
|
+
// deliberately even under an rw grant; 'rw' fails loudly when the
|
|
214
|
+
// session holds no grant (see core's resolve / the gate's broker).
|
|
215
|
+
const tierArg = args.tier === 'ro' || args.tier === 'rw' ? args.tier : undefined;
|
|
216
|
+
const profile = await opsAccess.resolve(spec.kind, args[spec.targetParam], exec.agent, tierArg ? { tier: tierArg } : undefined);
|
|
208
217
|
// Mint per-call credential tokens: buildCommand marks file fields via
|
|
209
218
|
// ref(); the display command (model-visible, logged) keeps the tokens,
|
|
210
219
|
// only the executed command carries the real values.
|
|
211
220
|
const tokens = createCredentialTokens(profile.name, profile.tier, profile.fields);
|
|
212
221
|
fullCommand = tokens.scrub(spec.buildCommand(profile.fields, command, tokens.ref));
|
|
213
|
-
|
|
222
|
+
// A confining executor (bash-sandbox) defaults a missing policy from
|
|
223
|
+
// the DEPLOYMENT, whose fallback workspace root is the dsh process
|
|
224
|
+
// cwd — '/' for a systemd service. bwrap then bind-mounts '/' over
|
|
225
|
+
// its own /dev tmpfs: ssh dies with "Couldn't open /dev/null", and
|
|
226
|
+
// worse, workspace-write degrades into the whole container root being
|
|
227
|
+
// writable. Mirror tool-bash: pass the calling session's resolved
|
|
228
|
+
// policy explicitly. Resolved per call via ctx.get, never cached —
|
|
229
|
+
// same discipline as the opsAccess lookup above.
|
|
230
|
+
let sandboxPolicy;
|
|
231
|
+
if (ctx.shell.sandboxMode !== undefined) {
|
|
232
|
+
const policyService = ctx.get('sandboxPolicy');
|
|
233
|
+
if (!policyService) {
|
|
234
|
+
const message = `${spec.name}: the mounted shell executor confines commands but the sandboxPolicy service is unavailable — refusing to run under the deployment fallback policy (wrong sandbox root). Mount dsh-sandbox-policy alongside the executor.`;
|
|
235
|
+
return { error: message, exitCode: -1, stdout: '', stderr: message, command: '' };
|
|
236
|
+
}
|
|
237
|
+
const session = exec.agent?.session;
|
|
238
|
+
sandboxPolicy = policyService.resolve(session !== undefined ? { session } : {});
|
|
239
|
+
}
|
|
240
|
+
const request = { command: tokens.executable(fullCommand), timeoutMs, signal: exec.signal, ...(sandboxPolicy !== undefined ? { sandboxPolicy } : {}) };
|
|
214
241
|
const resolved = ctx.shell.resolve(request);
|
|
215
242
|
const result = await ctx.shell.run(resolved);
|
|
216
243
|
// exitCode is null when the process died from a signal — normalize to
|
package/lib/types.d.ts
CHANGED
|
@@ -86,9 +86,14 @@ export type CredentialRef = (field: string) => string;
|
|
|
86
86
|
* dsh's ToolRunContext: `signal` (required there, optional here for tests)
|
|
87
87
|
* and the optional caller `agent`, whose `id` is the session the access gate
|
|
88
88
|
* keys grants on. The factory passes `agent` straight through to resolve —
|
|
89
|
-
* consumers stay identity-only and need no changes.
|
|
89
|
+
* consumers stay identity-only and need no changes. The agent's `session`
|
|
90
|
+
* (opaque here) is forwarded to the sandboxPolicy service so a confining
|
|
91
|
+
* shell executor sandboxes against the CALLING session's workspace, not the
|
|
92
|
+
* deployment fallback root.
|
|
90
93
|
*/
|
|
91
94
|
export interface ShellToolExec {
|
|
92
95
|
signal?: ShellExecRequest['signal'];
|
|
93
|
-
agent?: AccessAgent
|
|
96
|
+
agent?: AccessAgent & {
|
|
97
|
+
session?: unknown;
|
|
98
|
+
};
|
|
94
99
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elinpf/dsh-ops-shell-tool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Shared factory for ops-access consumer tools: standard shell result shape, output schema, render, and the resolve-per-call execute template.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
31
|
-
"@elinpf/dsh-ops-access": "^0.
|
|
31
|
+
"@elinpf/dsh-ops-access": "^0.4.0",
|
|
32
32
|
"@deepseek-ai/dsh-shell": "^0.1.0-rc.8",
|
|
33
33
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
|
|
34
34
|
},
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@deepseek-ai/dsh-tools": "0.0.1-rc.1",
|
|
39
39
|
"typescript": "^5.4.0",
|
|
40
40
|
"vitest": "^4.1.11",
|
|
41
|
-
"@elinpf/dsh-ops-access": "0.
|
|
41
|
+
"@elinpf/dsh-ops-access": "0.4.0"
|
|
42
42
|
},
|
|
43
43
|
"license": "MIT",
|
|
44
44
|
"publishConfig": {
|