@elinpf/dsh-ops-shell-tool 0.3.0 → 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 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 output = {
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
- const profile = await opsAccess.resolve(spec.kind, args[spec.targetParam], exec.agent);
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
- const request = { command: tokens.executable(fullCommand), timeoutMs, signal: exec.signal };
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.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.3.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.3.0"
41
+ "@elinpf/dsh-ops-access": "0.4.0"
42
42
  },
43
43
  "license": "MIT",
44
44
  "publishConfig": {