@exagone313/dsh-podman 0.2.0-rc.2

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 (165) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -0
  3. package/README.zh.md +41 -0
  4. package/cordis.patch.yml +17 -0
  5. package/dist/approval-reasons.d.ts +129 -0
  6. package/dist/approval-reasons.js +253 -0
  7. package/dist/approval-reasons.test.d.ts +1 -0
  8. package/dist/approval-reasons.test.js +115 -0
  9. package/dist/approval.d.ts +29 -0
  10. package/dist/approval.js +412 -0
  11. package/dist/approval.test.d.ts +1 -0
  12. package/dist/approval.test.js +462 -0
  13. package/dist/card-route.d.ts +15 -0
  14. package/dist/card-route.js +478 -0
  15. package/dist/card-route.test.d.ts +1 -0
  16. package/dist/card-route.test.js +181 -0
  17. package/dist/card-test-support.d.ts +15 -0
  18. package/dist/card-test-support.js +160 -0
  19. package/dist/client/ContainerCard.d.ts +6 -0
  20. package/dist/client/ContainerCard.js +79 -0
  21. package/dist/client/card-client.d.ts +6 -0
  22. package/dist/client/card-client.js +47 -0
  23. package/dist/client/card-protocol.d.ts +101 -0
  24. package/dist/client/card-protocol.js +10 -0
  25. package/dist/client/container-card-caches.d.ts +10 -0
  26. package/dist/client/container-card-caches.js +31 -0
  27. package/dist/client/container-card-controller.d.ts +107 -0
  28. package/dist/client/container-card-controller.js +207 -0
  29. package/dist/client/container-card-create-modal.d.ts +24 -0
  30. package/dist/client/container-card-create-modal.js +109 -0
  31. package/dist/client/container-card-directory-styles.d.ts +5 -0
  32. package/dist/client/container-card-directory-styles.js +246 -0
  33. package/dist/client/container-card-directory.d.ts +12 -0
  34. package/dist/client/container-card-directory.js +144 -0
  35. package/dist/client/container-card-editors.d.ts +30 -0
  36. package/dist/client/container-card-editors.js +116 -0
  37. package/dist/client/container-card-images.d.ts +37 -0
  38. package/dist/client/container-card-images.js +35 -0
  39. package/dist/client/container-card-paths.d.ts +20 -0
  40. package/dist/client/container-card-paths.js +88 -0
  41. package/dist/client/container-card-row.d.ts +30 -0
  42. package/dist/client/container-card-row.js +54 -0
  43. package/dist/client/container-card-secrets.d.ts +21 -0
  44. package/dist/client/container-card-secrets.js +71 -0
  45. package/dist/client/container-card-shared.d.ts +55 -0
  46. package/dist/client/container-card-shared.js +152 -0
  47. package/dist/client/container-card-styles.d.ts +21 -0
  48. package/dist/client/container-card-styles.js +143 -0
  49. package/dist/client/container-card-volumes.d.ts +12 -0
  50. package/dist/client/container-card-volumes.js +37 -0
  51. package/dist/client/container-card-workspace.d.ts +31 -0
  52. package/dist/client/container-card-workspace.js +24 -0
  53. package/dist/client/directory-picker.d.ts +12 -0
  54. package/dist/client/directory-picker.js +30 -0
  55. package/dist/client/index.d.ts +5 -0
  56. package/dist/client/index.js +4419 -0
  57. package/dist/client/locales.d.ts +9 -0
  58. package/dist/client/locales.js +370 -0
  59. package/dist/client/read-only-approval.d.ts +8 -0
  60. package/dist/client/read-only-approval.js +63 -0
  61. package/dist/client/slot-contract.d.ts +12 -0
  62. package/dist/client/slot-contract.js +4 -0
  63. package/dist/client/terminal-styles.d.ts +5 -0
  64. package/dist/client/terminal-styles.js +31 -0
  65. package/dist/client/tool-views.d.ts +8 -0
  66. package/dist/client/tool-views.js +241 -0
  67. package/dist/containers.test.d.ts +1 -0
  68. package/dist/containers.test.js +443 -0
  69. package/dist/daemons.test.d.ts +1 -0
  70. package/dist/daemons.test.js +153 -0
  71. package/dist/fs-provider.d.ts +23 -0
  72. package/dist/fs-provider.js +184 -0
  73. package/dist/fs-provider.test.d.ts +1 -0
  74. package/dist/fs-provider.test.js +260 -0
  75. package/dist/generated/version.d.ts +2 -0
  76. package/dist/generated/version.js +6 -0
  77. package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
  78. package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
  79. package/dist/grpc/runtime-client.d.ts +5 -0
  80. package/dist/grpc/runtime-client.js +43 -0
  81. package/dist/guest-rpc.d.ts +62 -0
  82. package/dist/guest-rpc.js +391 -0
  83. package/dist/images.test.d.ts +1 -0
  84. package/dist/images.test.js +79 -0
  85. package/dist/index.d.ts +22 -0
  86. package/dist/index.js +140 -0
  87. package/dist/misc.test.d.ts +1 -0
  88. package/dist/misc.test.js +141 -0
  89. package/dist/mount-enums.d.ts +22 -0
  90. package/dist/mount-enums.js +81 -0
  91. package/dist/mount-input.d.ts +14 -0
  92. package/dist/mount-input.js +64 -0
  93. package/dist/mounts.test.d.ts +1 -0
  94. package/dist/mounts.test.js +408 -0
  95. package/dist/output-reader.d.ts +34 -0
  96. package/dist/output-reader.js +87 -0
  97. package/dist/paths.test.d.ts +1 -0
  98. package/dist/paths.test.js +74 -0
  99. package/dist/preferences.d.ts +6 -0
  100. package/dist/preferences.js +27 -0
  101. package/dist/project-path.d.ts +6 -0
  102. package/dist/project-path.js +73 -0
  103. package/dist/project-path.test.d.ts +1 -0
  104. package/dist/project-path.test.js +52 -0
  105. package/dist/prompts.d.ts +13 -0
  106. package/dist/prompts.js +149 -0
  107. package/dist/public.d.ts +4 -0
  108. package/dist/public.js +63 -0
  109. package/dist/read-only-shell.d.ts +14 -0
  110. package/dist/read-only-shell.js +150 -0
  111. package/dist/read-only-shell.test.d.ts +1 -0
  112. package/dist/read-only-shell.test.js +127 -0
  113. package/dist/secrets.test.d.ts +1 -0
  114. package/dist/secrets.test.js +181 -0
  115. package/dist/settings-commands.test.d.ts +1 -0
  116. package/dist/settings-commands.test.js +549 -0
  117. package/dist/settings-create.test.d.ts +1 -0
  118. package/dist/settings-create.test.js +394 -0
  119. package/dist/settings-mounts.test.d.ts +1 -0
  120. package/dist/settings-mounts.test.js +467 -0
  121. package/dist/settings-paths.test.d.ts +1 -0
  122. package/dist/settings-paths.test.js +65 -0
  123. package/dist/settings-schema.d.ts +14 -0
  124. package/dist/settings-schema.js +14 -0
  125. package/dist/settings-workspaces.test.d.ts +1 -0
  126. package/dist/settings-workspaces.test.js +265 -0
  127. package/dist/spill-store.d.ts +17 -0
  128. package/dist/spill-store.js +54 -0
  129. package/dist/spill-store.test.d.ts +1 -0
  130. package/dist/spill-store.test.js +59 -0
  131. package/dist/subprocess.d.ts +9 -0
  132. package/dist/subprocess.js +369 -0
  133. package/dist/subprocess.test.d.ts +1 -0
  134. package/dist/subprocess.test.js +234 -0
  135. package/dist/test-support.d.ts +328 -0
  136. package/dist/test-support.js +437 -0
  137. package/dist/tool-defs.d.ts +26 -0
  138. package/dist/tool-defs.js +188 -0
  139. package/dist/tool-handlers.d.ts +2 -0
  140. package/dist/tool-handlers.js +546 -0
  141. package/dist/tool-params.d.ts +803 -0
  142. package/dist/tool-params.js +494 -0
  143. package/dist/tool-schemas.d.ts +2 -0
  144. package/dist/tool-schemas.js +5 -0
  145. package/dist/tool-views.d.ts +2 -0
  146. package/dist/tool-views.js +56 -0
  147. package/dist/views.test.d.ts +1 -0
  148. package/dist/views.test.js +23 -0
  149. package/dist/volumes.test.d.ts +1 -0
  150. package/dist/volumes.test.js +40 -0
  151. package/dist/workspace-binding.d.ts +40 -0
  152. package/dist/workspace-binding.js +275 -0
  153. package/dist/workspace-binding.test.d.ts +1 -0
  154. package/dist/workspace-binding.test.js +337 -0
  155. package/docs/architecture.md +173 -0
  156. package/docs/architecture.zh.md +129 -0
  157. package/docs/configuration.md +160 -0
  158. package/docs/configuration.zh.md +147 -0
  159. package/docs/development.md +142 -0
  160. package/docs/development.zh.md +135 -0
  161. package/docs/install-dsh-and-dsh-podman.md +165 -0
  162. package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
  163. package/docs/usage.md +393 -0
  164. package/docs/usage.zh.md +315 -0
  165. package/package.json +119 -0
@@ -0,0 +1,462 @@
1
+ // SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
2
+ //
3
+ // SPDX-License-Identifier: MIT
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { presetExec, sessionExec, testReadSession } from "./test-support.js";
10
+ import { PODMAN_OPS_AGENT_CORDIS_YML, PODMAN_OPS_PRESET_YML, READ_ONLY_TOOLS, SANDBOX_ESCALATION_REASON_PREFIX, TOOLS, approvalDecision, ensurePodmanOpsPreset, isSandboxEscalation, preExecutePolicy, summarizeArgs, } from "./index.js";
11
+ test("isSandboxEscalation claims only the harness's sandbox escalation", () => {
12
+ assert.equal(SANDBOX_ESCALATION_REASON_PREFIX, "escalate sandbox to ");
13
+ assert.equal(isSandboxEscalation({
14
+ toolName: "bash",
15
+ reason: "escalate sandbox to workspace-write: the user asked to write",
16
+ }), true);
17
+ assert.equal(isSandboxEscalation({
18
+ toolName: "pwsh",
19
+ reason: "escalate sandbox to danger-full-access: install a package",
20
+ }), true);
21
+ // The plugin's own prompts never match, so they keep prompting.
22
+ assert.equal(isSandboxEscalation({
23
+ toolName: "container_bash",
24
+ reason: 'Run a shell command in container "c": ls',
25
+ }), false);
26
+ assert.equal(isSandboxEscalation({
27
+ toolName: "dsh_podman_builtin_remount_read_only",
28
+ reason: 'Read-only mode blocks "bash" while a mount is read-write.',
29
+ }), false);
30
+ // A real approval for the same tool must not be claimed.
31
+ assert.equal(isSandboxEscalation({ toolName: "bash" }), false);
32
+ assert.equal(isSandboxEscalation({ toolName: "bash", reason: 42 }), false);
33
+ assert.equal(isSandboxEscalation(undefined), false);
34
+ });
35
+ test("the destructive mutations require approval", () => {
36
+ const approval = TOOLS.filter((tool) => tool.approval)
37
+ .map((tool) => tool.name)
38
+ .sort();
39
+ assert.deepEqual(approval, [
40
+ "container_mount_add",
41
+ "container_mount_remove",
42
+ "container_mount_update",
43
+ "container_path_add",
44
+ "container_path_remove",
45
+ "container_path_set",
46
+ "container_recreate",
47
+ "container_remove",
48
+ "container_secret_add",
49
+ "container_secret_remove",
50
+ "image_build",
51
+ "image_rebuild",
52
+ "image_rebuild_all",
53
+ "image_remove",
54
+ "secret_remove",
55
+ "volume_remove",
56
+ ]);
57
+ });
58
+ test("approvalDecision gates exactly the approval-flagged tools", () => {
59
+ const sampleArgs = {
60
+ image_build: {
61
+ imageId: "valkey",
62
+ parent: "archlinux",
63
+ packages: ["valkey"],
64
+ },
65
+ image_rebuild: { imageId: "valkey" },
66
+ image_remove: { imageId: "valkey" },
67
+ container_recreate: {
68
+ container: "valkey-ctr",
69
+ image: "localhost/dsh-podman/nginx:latest",
70
+ mounts: [{ project: "team", mode: "read_only" }],
71
+ },
72
+ container_remove: { container: "valkey-ctr" },
73
+ volume_remove: { name: "valkey-data" },
74
+ container_mount_add: {
75
+ container: "valkey-ctr",
76
+ kind: "volume",
77
+ volume: "valkey-data",
78
+ destination: "/data",
79
+ mode: "read_write",
80
+ },
81
+ container_mount_remove: {
82
+ container: "valkey-ctr",
83
+ kind: "volume",
84
+ volume: "valkey-data",
85
+ destination: "/data",
86
+ },
87
+ container_mount_update: {
88
+ container: "valkey-ctr",
89
+ kind: "volume",
90
+ volume: "valkey-data",
91
+ destination: "/data",
92
+ mode: "read_only",
93
+ },
94
+ container_path_set: {
95
+ container: "valkey-ctr",
96
+ paths: ["/opt/bin", "/usr/local/bin"],
97
+ },
98
+ container_path_add: { container: "valkey-ctr", path: "/opt/bin" },
99
+ container_path_remove: { container: "valkey-ctr", path: "/opt/bin" },
100
+ secret_remove: { name: "valkey-pass" },
101
+ container_secret_add: {
102
+ container: "valkey-ctr",
103
+ env: "REDIS_PASSWORD",
104
+ secret: "valkey-pass",
105
+ },
106
+ container_secret_remove: {
107
+ container: "valkey-ctr",
108
+ env: "REDIS_PASSWORD",
109
+ },
110
+ };
111
+ for (const tool of TOOLS) {
112
+ const decision = approvalDecision(tool.name, sampleArgs[tool.name] ?? {});
113
+ if (tool.approval === true) {
114
+ assert.ok(decision, `${tool.name} must ask for approval`);
115
+ assert.equal(decision.kind, "ask");
116
+ assert.ok((decision.reason ?? "").length > 0, `${tool.name} ask reason`);
117
+ }
118
+ else {
119
+ assert.equal(decision, undefined, `${tool.name} must not ask`);
120
+ }
121
+ }
122
+ assert.equal(approvalDecision("no_such_tool"), undefined);
123
+ });
124
+ test("summarizeArgs renders the approval reason for each gated tool", () => {
125
+ assert.equal(summarizeArgs("image_build", {
126
+ imageId: "valkey",
127
+ parent: "archlinux",
128
+ packages: ["valkey"],
129
+ }), 'Build image "valkey" from "archlinux" with packages: valkey.');
130
+ assert.equal(summarizeArgs("image_build", {
131
+ imageId: "dev",
132
+ parent: "archlinux",
133
+ packages: ["git", "curl", "tmux", "vim", "zsh", "openssh", "jq", "ripgrep", "make", "cc", "go"],
134
+ }), 'Build image "dev" from "archlinux" with packages: git, curl, tmux, vim, zsh, openssh, jq, ripgrep, +3 more.');
135
+ assert.equal(summarizeArgs("image_rebuild", { imageId: "valkey" }), 'Rebuild image "valkey".');
136
+ assert.equal(summarizeArgs("image_rebuild_all", {}), "Rebuild all images.");
137
+ assert.equal(summarizeArgs("image_remove", { imageId: "valkey" }), 'Remove image "valkey".');
138
+ assert.equal(summarizeArgs("container_recreate", { container: "valkey-ctr" }), 'Recreate container "valkey-ctr".');
139
+ assert.equal(summarizeArgs("container_remove", { container: "valkey-ctr" }), 'Remove container "valkey-ctr".');
140
+ assert.equal(summarizeArgs("volume_remove", { name: "valkey-data" }), 'Remove volume "valkey-data".');
141
+ assert.equal(summarizeArgs("container_start", {
142
+ container: "web",
143
+ image: "localhost/dsh-podman/nginx:latest",
144
+ mounts: [
145
+ { project: "team/src", mode: "read_only" },
146
+ { project: "team", destination: "/workspace/team", mode: "read_write" },
147
+ ],
148
+ }), 'Start container "web" from image "localhost/dsh-podman/nginx:latest" with mounts: project "team/src" (read-only), project "team" at "/workspace/team" (read-write).');
149
+ assert.equal(summarizeArgs("container_recreate", {
150
+ container: "valkey-ctr",
151
+ image: "localhost/dsh-podman/nginx:latest",
152
+ mounts: [
153
+ { project: "team/src", mode: "read_only" },
154
+ { project: "team", destination: "/workspace/team", mode: "read_write" },
155
+ ],
156
+ }), 'Recreate container "valkey-ctr" from image "localhost/dsh-podman/nginx:latest" with mounts: project "team/src" (read-only), project "team" at "/workspace/team" (read-write).');
157
+ assert.equal(summarizeArgs("container_mount_add", {
158
+ container: "valkey-ctr",
159
+ kind: "volume",
160
+ volume: "valkey-data",
161
+ destination: "/data",
162
+ mode: "read_write",
163
+ }), 'Add mount to container "valkey-ctr": volume "valkey-data" at "/data" (read-write).');
164
+ assert.equal(summarizeArgs("container_mount_update", {
165
+ container: "valkey-ctr",
166
+ kind: "volume",
167
+ volume: "valkey-data",
168
+ destination: "/data",
169
+ mode: "read_write",
170
+ }), 'Update mount in container "valkey-ctr": remount volume "valkey-data" at "/data" to read-write.');
171
+ assert.equal(summarizeArgs("container_mount_add", {
172
+ container: "valkey-ctr",
173
+ kind: "project",
174
+ project: "team/src",
175
+ destination: "/workspace/team",
176
+ mode: "read_only",
177
+ }), 'Add mount to container "valkey-ctr": project "team/src" at "/workspace/team" (read-only).');
178
+ assert.equal(summarizeArgs("container_mount_add", {
179
+ container: "valkey-ctr",
180
+ kind: "tmpfs",
181
+ destination: "/dev/shm",
182
+ mode: "read_write",
183
+ }), 'Add mount to container "valkey-ctr": tmpfs at "/dev/shm".');
184
+ assert.equal(summarizeArgs("container_mount_remove", {
185
+ container: "valkey-ctr",
186
+ kind: "volume",
187
+ volume: "valkey-data",
188
+ destination: "/data",
189
+ }), 'Remove mount from container "valkey-ctr": volume "valkey-data" at "/data".');
190
+ assert.equal(summarizeArgs("container_mount_remove", {
191
+ container: "valkey-ctr",
192
+ kind: "project",
193
+ project: "team/src",
194
+ }), 'Remove mount from container "valkey-ctr": project "team/src".');
195
+ assert.equal(summarizeArgs("secret_remove", { name: "valkey-pass" }), 'Remove secret "valkey-pass".');
196
+ assert.equal(summarizeArgs("container_secret_add", {
197
+ container: "valkey-ctr",
198
+ env: "REDIS_PASSWORD",
199
+ secret: "valkey-pass",
200
+ }), 'Attach secret "valkey-pass" to container "valkey-ctr" as "REDIS_PASSWORD".');
201
+ assert.equal(summarizeArgs("container_secret_remove", {
202
+ container: "valkey-ctr",
203
+ env: "REDIS_PASSWORD",
204
+ }), 'Detach secret env var "REDIS_PASSWORD" from container "valkey-ctr".');
205
+ assert.equal(summarizeArgs("container_mount_add", {
206
+ container: "valkey-ctr",
207
+ kind: "secret",
208
+ secret: "valkey-tls",
209
+ destination: "/run/secrets/tls",
210
+ }), 'Add mount to container "valkey-ctr": secret "valkey-tls" at "/run/secrets/tls".');
211
+ assert.equal(summarizeArgs("container_mount_remove", {
212
+ container: "valkey-ctr",
213
+ kind: "secret",
214
+ secret: "valkey-tls",
215
+ }), 'Remove mount from container "valkey-ctr": secret "valkey-tls".');
216
+ });
217
+ test("summarizeArgs tolerates missing or malformed arguments", () => {
218
+ assert.equal(summarizeArgs("image_build", {}), "");
219
+ assert.equal(summarizeArgs("container_mount_add", { container: "c" }), "");
220
+ assert.equal(summarizeArgs("image_list", { imageId: "x" }), "");
221
+ assert.equal(summarizeArgs("container_recreate", { container: "c" }), 'Recreate container "c".');
222
+ assert.equal(summarizeArgs("container_recreate", {
223
+ container: "c",
224
+ image: "img",
225
+ mounts: [{ mode: "read_only" }, "garbage", 42],
226
+ }), 'Recreate container "c" from image "img".');
227
+ });
228
+ test("preExecutePolicy asks for gated tools and delegates the rest", async () => {
229
+ const asked = (await preExecutePolicy({ name: "image_remove", arguments: { imageId: "valkey" } }, () => Promise.resolve({ kind: "allow" })));
230
+ assert.equal(asked.kind, "ask");
231
+ assert.equal(asked.reason, 'Remove image "valkey".');
232
+ let delegated = false;
233
+ const allowed = (await preExecutePolicy({ name: "image_list" }, () => {
234
+ delegated = true;
235
+ return Promise.resolve({ kind: "allow" });
236
+ }));
237
+ assert.equal(delegated, true, "non-gated tools must delegate to next()");
238
+ assert.equal(allowed.kind, "allow");
239
+ });
240
+ test("read-only permission allows get/list tools and denies the rest", async () => {
241
+ for (const name of READ_ONLY_TOOLS) {
242
+ let delegated = false;
243
+ const result = (await preExecutePolicy(sessionExec(name), () => {
244
+ delegated = true;
245
+ return Promise.resolve({ kind: "allow" });
246
+ }, undefined, undefined, testReadSession));
247
+ assert.equal(delegated, true, `${name} must be allowed under read-only`);
248
+ assert.equal(result.kind, "allow");
249
+ }
250
+ for (const name of ["image_build", "container_start", "container_remove", "volume_remove"]) {
251
+ const result = (await preExecutePolicy(sessionExec(name), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
252
+ assert.equal(result.kind, "deny", `${name} must be denied under read-only`);
253
+ assert.ok(result.reason.includes("read-only"), `${name} deny reason`);
254
+ }
255
+ for (const name of ["read", "glob", "grep", "job_output"]) {
256
+ let delegated = false;
257
+ const result = (await preExecutePolicy(sessionExec(name), () => {
258
+ delegated = true;
259
+ return Promise.resolve({ kind: "allow" });
260
+ }, undefined, undefined, testReadSession));
261
+ assert.equal(delegated, true, `${name} must delegate under read-only`);
262
+ assert.equal(result.kind, "allow");
263
+ }
264
+ });
265
+ test("read-only denies the built-in file and shell tools", async () => {
266
+ for (const name of ["write", "edit", "bash", "pwsh"]) {
267
+ const result = (await preExecutePolicy(sessionExec(name), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
268
+ assert.equal(result.kind, "deny", `${name} must be denied under read-only`);
269
+ assert.equal(result.reason, `Denied: the session is read-only, but "${name}" can modify files.`);
270
+ }
271
+ });
272
+ test("workspace-write and full access delegate the built-in file and shell tools", async () => {
273
+ for (const facts of [{ mode: "workspace-write", policy: "ask" }, { policy: "never" }]) {
274
+ for (const name of ["write", "edit", "bash", "pwsh"]) {
275
+ let delegated = false;
276
+ const result = (await preExecutePolicy(sessionExec(name, facts), () => {
277
+ delegated = true;
278
+ return Promise.resolve({ kind: "allow" });
279
+ }, undefined, undefined, testReadSession));
280
+ assert.equal(delegated, true, `${name} must delegate`);
281
+ assert.equal(result.kind, "allow");
282
+ }
283
+ }
284
+ });
285
+ test("the built-in file and shell tools delegate without a session", async () => {
286
+ for (const name of ["write", "edit", "bash", "pwsh"]) {
287
+ let delegated = false;
288
+ await preExecutePolicy({ name }, () => {
289
+ delegated = true;
290
+ return Promise.resolve({ kind: "allow" });
291
+ }, undefined, undefined, testReadSession);
292
+ assert.equal(delegated, true, `${name} must delegate without a session`);
293
+ }
294
+ });
295
+ test("full-access (approval never) runs tools without asking", async () => {
296
+ for (const name of ["image_build", "container_replace", "container_remove"]) {
297
+ let delegated = false;
298
+ const result = (await preExecutePolicy(sessionExec(name, { policy: "never" }), () => {
299
+ delegated = true;
300
+ return Promise.resolve({ kind: "allow" });
301
+ }, undefined, undefined, testReadSession));
302
+ assert.equal(delegated, true, `${name} must not ask under full access`);
303
+ assert.equal(result.kind, "allow");
304
+ }
305
+ });
306
+ test("workspace-write keeps the ask-based approval", async () => {
307
+ const asked = (await preExecutePolicy({
308
+ name: "image_build",
309
+ arguments: {
310
+ imageId: "valkey",
311
+ parent: "archlinux",
312
+ packages: ["valkey"],
313
+ },
314
+ agent: { session: { facts: { mode: "workspace-write", policy: "ask" } } },
315
+ }, () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
316
+ assert.equal(asked.kind, "ask");
317
+ assert.equal(asked.reason, 'Build image "valkey" from "archlinux" with packages: valkey.');
318
+ });
319
+ test("read-only wins over a never approval policy", async () => {
320
+ const result = (await preExecutePolicy(sessionExec("image_build", { mode: "read-only", policy: "never" }), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
321
+ assert.equal(result.kind, "deny", "read-only must deny mutating tools even under full access");
322
+ });
323
+ test("podman-ops preset asks for its approval-gated tools only", async () => {
324
+ const workspaceWrite = { mode: "workspace-write", policy: "ask" };
325
+ const asked = (await preExecutePolicy(presetExec("container_bash", "podman-ops", { container: "valkey-ctr", command: "valkey-cli ping" }, workspaceWrite), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
326
+ assert.equal(asked.kind, "ask");
327
+ assert.equal(asked.reason, 'Run a shell command in container "valkey-ctr": valkey-cli ping');
328
+ const daemon = (await preExecutePolicy(presetExec("daemon_start", "podman-ops", { container: "valkey-ctr", name: "v1", argv: ["valkey-server"], uid: 1001 }, workspaceWrite), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
329
+ assert.equal(daemon.kind, "ask");
330
+ assert.equal(daemon.reason, 'Start daemon "v1" in container "valkey-ctr": valkey-server (uid 1001)');
331
+ });
332
+ test("podman-ops approval does not leak into other presets", async () => {
333
+ const workspaceWrite = { mode: "workspace-write", policy: "ask" };
334
+ for (const preset of ["standard", undefined]) {
335
+ let delegated = false;
336
+ const result = (await preExecutePolicy(presetExec("container_bash", preset, { container: "c", command: "ls" }, workspaceWrite), () => {
337
+ delegated = true;
338
+ return Promise.resolve({ kind: "allow" });
339
+ }, undefined, undefined, testReadSession));
340
+ assert.equal(delegated, true, `${preset}: container_bash must delegate`);
341
+ assert.equal(result.kind, "allow");
342
+ }
343
+ });
344
+ test("podman-ops keeps open tools ungated and respects permissions", async () => {
345
+ const workspaceWrite = { mode: "workspace-write", policy: "ask" };
346
+ let delegated = false;
347
+ const open = (await preExecutePolicy(presetExec("image_list", "podman-ops", {}, workspaceWrite), () => {
348
+ delegated = true;
349
+ return Promise.resolve({ kind: "allow" });
350
+ }, undefined, undefined, testReadSession));
351
+ assert.equal(delegated, true, "image_list must stay open in podman-ops");
352
+ assert.equal(open.kind, "allow");
353
+ const denied = (await preExecutePolicy(presetExec("container_bash", "podman-ops", { container: "c", command: "ls" }, { mode: "read-only" }), () => Promise.resolve({ kind: "allow" }), undefined, undefined, testReadSession));
354
+ assert.equal(denied.kind, "deny", "read-only wins over podman-ops approval");
355
+ let fullDelegated = false;
356
+ const full = (await preExecutePolicy(presetExec("daemon_start", "podman-ops", { container: "c", argv: ["x"] }, { policy: "never" }), () => {
357
+ fullDelegated = true;
358
+ return Promise.resolve({ kind: "allow" });
359
+ }, undefined, undefined, testReadSession));
360
+ assert.equal(fullDelegated, true, "full access must not ask");
361
+ assert.equal(full.kind, "allow");
362
+ });
363
+ test("summarizeArgs renders reasons for the podman-ops gated tools", () => {
364
+ assert.equal(summarizeArgs("container_exec", { container: "c", argv: ["python", "run.py", "--x", "1", "--y", "2", "--z", "3", "--w", "4", "--v", "5"] }), 'Run a command in container "c": python run.py --x 1 --y 2 --z 3 …');
365
+ assert.equal(summarizeArgs("container_write", { container: "c", file_path: "/etc/valkey/valkey.conf" }), 'Write "/etc/valkey/valkey.conf" in container "c".');
366
+ assert.equal(summarizeArgs("container_edit", { container: "c", file_path: "/etc/valkey/valkey.conf" }), 'Edit "/etc/valkey/valkey.conf" in container "c".');
367
+ assert.equal(summarizeArgs("container_bash", { container: "c", command: "ping -c 1 8.8.8.8" }), 'Run a shell command in container "c": ping -c 1 8.8.8.8');
368
+ assert.equal(summarizeArgs("container_bash", { container: "c", command: "ls", workdir: "/srv" }), 'Run a shell command in container "c" (cwd "/srv"): ls');
369
+ assert.equal(summarizeArgs("container_exec", { container: "c", argv: ["ls"], workdir: "/srv" }), 'Run a command in container "c" (cwd "/srv"): ls');
370
+ });
371
+ test("summarizeArgs names the process identity and groups", () => {
372
+ assert.equal(summarizeArgs("container_bash", {
373
+ container: "c",
374
+ command: "id",
375
+ uid: 1000,
376
+ gid: 1000,
377
+ groups: [3000, 4000],
378
+ }), 'Run a shell command in container "c": id (uid 1000, gid 1000, groups 3000, 4000)');
379
+ assert.equal(summarizeArgs("container_exec", { container: "c", argv: ["id"], gid: 2000 }), 'Run a command in container "c": id (gid 2000)');
380
+ assert.equal(summarizeArgs("daemon_start", {
381
+ container: "c",
382
+ name: "web",
383
+ argv: ["nginx"],
384
+ uid: 1000,
385
+ groups: [3000],
386
+ }), 'Start daemon "web" in container "c": nginx (uid 1000, groups 3000)');
387
+ // An empty groups list is not worth naming.
388
+ assert.equal(summarizeArgs("container_exec", { container: "c", argv: ["id"], groups: [] }), 'Run a command in container "c": id');
389
+ });
390
+ test("Podman-ops preset content covers the recent tools", () => {
391
+ for (const tool of [
392
+ "image_rebuild_all",
393
+ "secret_list",
394
+ "secret_create",
395
+ "secret_remove",
396
+ "container_secret_add",
397
+ "container_secret_remove",
398
+ ]) {
399
+ assert.ok(PODMAN_OPS_AGENT_CORDIS_YML.includes(tool), `podman-ops composition must mention ${tool}`);
400
+ }
401
+ assert.ok(PODMAN_OPS_PRESET_YML.includes("secrets"), "podman-ops metadata must mention secrets");
402
+ // The persona plugin takes its prose as `prefix` (a required field); `text`
403
+ // is not part of its schema and would make the preset fail to load.
404
+ assert.match(PODMAN_OPS_AGENT_CORDIS_YML, /prefix:/);
405
+ assert.doesNotMatch(PODMAN_OPS_AGENT_CORDIS_YML, /\btext:/);
406
+ });
407
+ test("Podman-ops preset writer overwrites existing content", () => {
408
+ const dir = mkdtempSync(join(tmpdir(), "dsh-podman-"));
409
+ ensurePodmanOpsPreset(undefined, dir);
410
+ const composition = join(dir, "agent.cordis.yml");
411
+ const metadata = join(dir, "preset.yml");
412
+ assert.equal(readFileSync(composition, "utf8"), PODMAN_OPS_AGENT_CORDIS_YML);
413
+ assert.equal(readFileSync(metadata, "utf8"), PODMAN_OPS_PRESET_YML);
414
+ writeFileSync(composition, "# stale user copy\n");
415
+ ensurePodmanOpsPreset(undefined, dir);
416
+ assert.equal(readFileSync(composition, "utf8"), PODMAN_OPS_AGENT_CORDIS_YML, "a second load must overwrite the stale copy");
417
+ });
418
+ test("summarizeArgs includes PATH additions for container start/recreate", () => {
419
+ assert.equal(summarizeArgs("container_start", { container: "web", paths: ["/opt/bin"] }), 'Start container "web" with PATH additions: "/opt/bin".');
420
+ assert.equal(summarizeArgs("container_recreate", {
421
+ container: "c",
422
+ image: "img",
423
+ paths: ["/opt/bin", "/usr/local/bin"],
424
+ }), 'Recreate container "c" from image "img" with PATH additions: "/opt/bin", "/usr/local/bin".');
425
+ // An empty list is not worth naming.
426
+ assert.equal(summarizeArgs("container_start", { container: "web", paths: [] }), 'Start container "web".');
427
+ });
428
+ test("an approval ask omits the reason when no summary can be derived", () => {
429
+ assert.deepEqual(approvalDecision("container_mount_remove", { container: "c" }), {
430
+ kind: "ask",
431
+ });
432
+ });
433
+ test("summarizeArgs includes env keys for container start/recreate", () => {
434
+ assert.equal(summarizeArgs("container_start", {
435
+ container: "web",
436
+ env: { A: "1", B: "2" },
437
+ }), 'Start container "web" with env: A, B.');
438
+ assert.equal(summarizeArgs("container_start", {
439
+ container: "web",
440
+ image: "localhost/dsh-podman/nginx:latest",
441
+ mounts: [{ project: "team", mode: "read_only" }],
442
+ env: { A: "1", B: "2", C: "3" },
443
+ }), 'Start container "web" from image "localhost/dsh-podman/nginx:latest" with mounts: project "team" (read-only) with env: A, B, C.');
444
+ assert.equal(summarizeArgs("container_start", {
445
+ container: "web",
446
+ env: { A: "1", B: "2", C: "3", D: "4", E: "5", F: "6", G: "7", H: "8", I: "9", J: "10" },
447
+ }), 'Start container "web" with env: A, B, C, D, E, F, G, H, +2 more.');
448
+ assert.equal(summarizeArgs("container_recreate", {
449
+ container: "c",
450
+ image: "img",
451
+ env: { A: "1" },
452
+ }), 'Recreate container "c" from image "img" with env: A.');
453
+ assert.equal(summarizeArgs("container_recreate", { container: "c", env: {} }), 'Recreate container "c".');
454
+ });
455
+ test("approval prompts name the resolved path", () => {
456
+ assert.equal(summarizeArgs("container_write", { container: "c", file_path: "notes.md" }, "/projects/team"), 'Write "/projects/team/notes.md" in container "c".');
457
+ assert.equal(summarizeArgs("container_edit", { container: "c", file_path: "notes.md" }, "/projects/team"), 'Edit "/projects/team/notes.md" in container "c".');
458
+ // Without a session cwd, or for a path that cannot resolve, the prompt still
459
+ // renders with the value as given.
460
+ assert.equal(summarizeArgs("container_write", { container: "c", file_path: "notes.md" }), 'Write "notes.md" in container "c".');
461
+ assert.equal(summarizeArgs("container_write", { container: "c", file_path: "../x" }, "/projects/team"), 'Write "../x" in container "c".');
462
+ });
@@ -0,0 +1,15 @@
1
+ import { type CardSnapshot } from "./client/card-protocol.js";
2
+ import { type WorkspaceResolver } from "./workspace-binding.js";
3
+ /** Build the live snapshot the card renders. */
4
+ export declare function cardSnapshot(resolver: WorkspaceResolver, workspaceRegistry: any): Promise<CardSnapshot>;
5
+ /**
6
+ * Run one card command, returning the notice to show (usually empty). `ctx` is
7
+ * the settings-injected context, read only for the session locale.
8
+ */
9
+ export declare function runCommand(ctx: any, resolver: WorkspaceResolver, workspaceRegistry: any, raw: Record<string, unknown>): Promise<string>;
10
+ /**
11
+ * Register the card's authenticated fetch route. It is served below the
12
+ * harness API path, so the carrier applies its Host/Origin fence and browser
13
+ * authentication before the handler runs.
14
+ */
15
+ export declare function registerCardRoute(ctx: any, resolver: WorkspaceResolver, workspaceRegistry?: any): void;