@superjackfruitlabs/salaam 0.0.4 → 0.0.5

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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
@@ -1,7 +1,8 @@
1
1
  import { COMMAND_NETWORK_PORT, inSandboxRelaySource } from './command-network.js';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
3
  import { execFileSync } from 'node:child_process';
4
- import { isAbsolute } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, isAbsolute, join, sep } from 'node:path';
5
6
  import { ServiceError } from './protocol.js';
6
7
 
7
8
  /**
@@ -25,9 +26,87 @@ export interface Bwrap {
25
26
  }
26
27
  export interface Seatbelt {
27
28
  path: string;
29
+ /** The active Apple developer dir (`xcode-select -p`) when it holds a real git. /usr/bin/git is
30
+ * a shim that needs Xcode's licence preferences, which the profile does not open; commands get
31
+ * the real tools first on PATH instead (step 0, 2026-10-31). */
32
+ developerDir?: string;
28
33
  }
29
34
  export type SandboxKind = 'bwrap' | 'seatbelt' | 'none';
30
35
 
36
+ /**
37
+ * Phase 1 (2026-10-31): read-only paths outside the workspace an operator lets local commands use
38
+ * (a toolchain, a package cache, `~/.gitconfig`). Never the root, home itself or an ancestor of
39
+ * it, and never a credential directory or file, or an ancestor of one. `toolchain` expands to the
40
+ * ones that exist here: the prefix of the node running Salaam, and home's package caches and
41
+ * toolchains. Writes stay inside the workspace whatever is listed.
42
+ */
43
+ export const READ_PATHS_TOOLCHAIN = 'toolchain';
44
+ const CREDENTIALS = [
45
+ '.ssh',
46
+ '.gnupg',
47
+ '.aws',
48
+ '.azure',
49
+ '.config/gcloud',
50
+ '.config/gh',
51
+ '.docker',
52
+ '.kube',
53
+ '.netrc',
54
+ '.npmrc',
55
+ '.pypirc',
56
+ '.git-credentials',
57
+ '.claude',
58
+ '.codex',
59
+ '.config/salaam',
60
+ 'Library/Keychains',
61
+ ];
62
+ const TOOLCHAIN = ['.npm', '.nvm', '.cargo', '.rustup', 'go', '.gitconfig', '.config/git'];
63
+ function within(path: string, base: string): boolean {
64
+ return path === base || path.startsWith(base.endsWith(sep) ? base : base + sep);
65
+ }
66
+ export function resolveReadPaths(entries: readonly string[], home = homedir()): string[] {
67
+ const refuse = (path: string, why: string): never => {
68
+ throw new ServiceError('invalid_field', `local_sandbox_read_paths: ${path} ${why}`);
69
+ };
70
+ const out: string[] = [];
71
+ const add = (path: string) => {
72
+ if (!isAbsolute(path)) refuse(path, 'must be an absolute path');
73
+ if (/["\\\x00-\x1f\x7f]/.test(path)) refuse(path, 'has a character Seatbelt cannot quote');
74
+ const p = path.length > 1 ? path.replace(/\/+$/, '') : path;
75
+ if (p === '/') refuse(p, 'is the whole filesystem');
76
+ if (within(home, p)) refuse(p, 'would expose the whole home directory');
77
+ for (const c of CREDENTIALS) {
78
+ const secret = join(home, c);
79
+ if (within(p, secret) || within(secret, p)) refuse(p, `covers credentials (${c})`);
80
+ }
81
+ if (!out.includes(p)) out.push(p);
82
+ };
83
+ for (const entry of entries) {
84
+ if (typeof entry !== 'string') refuse(String(entry), 'must be a string');
85
+ if (entry !== READ_PATHS_TOOLCHAIN) {
86
+ add(entry);
87
+ continue;
88
+ }
89
+ // The node running Salaam, so `node`/`npx`/`npm` resolve inside the sandbox.
90
+ const nodePrefix = dirname(dirname(realpathSync(process.execPath)));
91
+ if (!within(home, nodePrefix)) add(nodePrefix);
92
+ for (const t of TOOLCHAIN) if (existsSync(join(home, t))) add(join(home, t));
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** The active developer dir when it has a real git, else undefined. */
98
+ function developerDir(): string | undefined {
99
+ try {
100
+ const dir = execFileSync('/usr/bin/xcode-select', ['-p'], {
101
+ timeout: 5000,
102
+ encoding: 'utf8',
103
+ }).trim();
104
+ return isAbsolute(dir) && existsSync(join(dir, 'usr/bin/git')) ? dir : undefined;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
31
110
  /** The pinned bubblewrap: its absolute path and the version it reports, or null when unusable. */
32
111
  export function findBwrap(candidates = ['/usr/bin/bwrap', '/bin/bwrap']): Bwrap | null {
33
112
  for (const path of candidates) {
@@ -61,6 +140,8 @@ export function sandboxArgv(
61
140
  /** Broker network: the host relay's private directory and the node that runs the in-sandbox
62
141
  * relay. The network stays unshared; only the relay socket and node are bound in. */
63
142
  broker?: { dir: string; node: string };
143
+ /** Operator-allowed read-only paths outside the workspace (resolveReadPaths). */
144
+ readPaths?: readonly string[];
64
145
  },
65
146
  ): string[] {
66
147
  if (level === 'full-access') return argv;
@@ -100,6 +181,8 @@ export function sandboxArgv(
100
181
  '/dev',
101
182
  '--tmpfs',
102
183
  '/tmp',
184
+ // Operator-allowed toolchains, read-only, at their own paths (a symlink into one resolves).
185
+ ...(options.readPaths ?? []).flatMap((p) => ['--ro-bind-try', p, p]),
103
186
  level === 'read-only' ? '--ro-bind' : '--bind',
104
187
  root,
105
188
  root,
@@ -153,7 +236,8 @@ export function findSeatbelt(path = '/usr/bin/sandbox-exec'): Seatbelt | null {
153
236
  ],
154
237
  { timeout: 5000, stdio: 'ignore' },
155
238
  );
156
- return { path };
239
+ const dev = developerDir();
240
+ return dev ? { path, developerDir: dev } : { path };
157
241
  } catch {
158
242
  return null;
159
243
  }
@@ -176,11 +260,27 @@ export function seatbeltProfile(
176
260
  level: 'read-only' | 'workspace-write',
177
261
  root: string,
178
262
  tmp: string,
179
- /** brokerPort: the host relay's loopback port, the only place a broker-network command may connect. */
180
- options: { network: boolean; brokerPort?: number },
263
+ /** brokerPort: the host relay's loopback port, the only place a broker-network command may connect.
264
+ * readPaths: operator-allowed read-only paths outside the workspace. developerDir: the active
265
+ * developer dir, when it lies outside the system paths below. */
266
+ options: {
267
+ network: boolean;
268
+ brokerPort?: number;
269
+ readPaths?: readonly string[];
270
+ developerDir?: string;
271
+ /** A command the person approved to listen (operator ruling 2026-11-01, option C). Seatbelt
272
+ * cannot limit this to loopback: "localhost" in its filters matches every local address. */
273
+ listen?: boolean;
274
+ },
181
275
  ): string {
182
276
  const w = sbplPath(root, 'workspace path'),
183
277
  t = sbplPath(tmp, 'tmp path');
278
+ const extra = [
279
+ ...(options.readPaths ?? []),
280
+ ...(options.developerDir ? [options.developerDir] : []),
281
+ ]
282
+ .map((p) => `(subpath ${sbplPath(p, 'read path')})`)
283
+ .join(' ');
184
284
  const system = [
185
285
  '/usr',
186
286
  '/bin',
@@ -208,12 +308,18 @@ export function seatbeltProfile(
208
308
  '(allow signal (target same-sandbox))',
209
309
  '(allow sysctl-read)',
210
310
  '(allow file-read-metadata)',
211
- `(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t}))`,
311
+ `(allow file-read* (literal "/") ${system} (subpath ${w}) (subpath ${t})${extra ? ' ' + extra : ''})`,
212
312
  level === 'workspace-write'
213
313
  ? `(allow file-write* (subpath ${w}) (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`
214
314
  : `(allow file-write* (subpath ${t}) ${devices} (regex #"^/dev/fd/"))`,
215
315
  `(allow file-ioctl ${devices} (regex #"^/dev/fd/"))`,
216
316
  ...(options.network ? ['(allow network*)'] : []),
317
+ ...(options.listen && level === 'workspace-write' && !options.network
318
+ ? [
319
+ '(allow network-bind (local ip "localhost:*"))',
320
+ '(allow network-inbound (local ip "localhost:*"))',
321
+ ]
322
+ : []),
217
323
  ...(options.brokerPort !== undefined && !options.network
218
324
  ? [`(allow network-outbound (remote tcp "localhost:${brokerPort(options.brokerPort)}"))`]
219
325
  : []),
@@ -226,7 +332,12 @@ export function seatbeltArgv(
226
332
  tmp: string,
227
333
  argv: string[],
228
334
  seatbelt: Seatbelt | null,
229
- options: { network: boolean; brokerPort?: number },
335
+ options: {
336
+ network: boolean;
337
+ brokerPort?: number;
338
+ readPaths?: readonly string[];
339
+ listen?: boolean;
340
+ },
230
341
  ): string[] {
231
342
  if (level === 'full-access') return argv;
232
343
  if (!seatbelt)
@@ -234,12 +345,21 @@ export function seatbeltArgv(
234
345
  'sandbox_unavailable',
235
346
  'This host has no usable Seatbelt, so it cannot run a sandboxed command; an operator can choose full access explicitly',
236
347
  );
348
+ const dev = seatbelt.developerDir;
349
+ // git reads ~/.gitconfig and fails hard when it may not; without the operator allowing it, the
350
+ // command runs with no global git config rather than an error.
351
+ const gitConfig = join(homedir(), '.gitconfig');
352
+ const gitConfigAllowed = (options.readPaths ?? []).some((p) => within(gitConfig, p));
237
353
  return [
238
354
  seatbelt.path,
239
355
  '-p',
240
- seatbeltProfile(level, root, tmp, options),
356
+ seatbeltProfile(level, root, tmp, { ...options, ...(dev ? { developerDir: dev } : {}) }),
241
357
  '/usr/bin/env',
242
358
  `TMPDIR=${tmp}/`,
359
+ ...(dev
360
+ ? [`PATH=${join(dev, 'usr/bin')}:${process.env.PATH ?? '/usr/bin:/bin:/usr/sbin:/sbin'}`]
361
+ : []),
362
+ ...(gitConfigAllowed ? [] : ['GIT_CONFIG_GLOBAL=/dev/null']),
243
363
  ...argv,
244
364
  ];
245
365
  }
@@ -0,0 +1,175 @@
1
+ import { lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { SLASH_COMMANDS } from './tui-commands.js';
5
+
6
+ /** A saved prompt the person types as `/name args` (Phase 4b). It only ever produces a prompt:
7
+ * it never runs a command, grants a tool or changes a mode or rule, so a repository's own commands
8
+ * are safe to load without trusting the repository. */
9
+ export interface CustomCommand {
10
+ name: string;
11
+ scope: 'repo' | 'user';
12
+ path: string;
13
+ description: string;
14
+ argumentHint?: string;
15
+ /** Read for display; a command does not switch the session's model. */
16
+ model?: string;
17
+ body: string;
18
+ }
19
+
20
+ const MAX_FILE = 32 * 1024;
21
+ const MAX_FILES = 200;
22
+ const NAME = /^[a-z0-9][a-z0-9_-]*$/i;
23
+ const builtIn = new Set(SLASH_COMMANDS.flatMap((c) => [c.name, ...c.aliases]));
24
+
25
+ export function defaultUserCommandsDir(env = process.env): string {
26
+ return join(env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), 'salaam', 'commands');
27
+ }
28
+
29
+ /** Repository commands live in `<repo>/.salaam/commands`, yours in `~/.config/salaam/commands`.
30
+ * A subdirectory becomes a namespace (`frontend/component.md` is `/frontend:component`), and your
31
+ * command wins over a repository's of the same name. Built-in commands always win. */
32
+ export function loadCustomCommands(
33
+ repo: string,
34
+ userDir = defaultUserCommandsDir(),
35
+ ): { commands: CustomCommand[]; problems: string[] } {
36
+ const problems: string[] = [];
37
+ const found = new Map<string, CustomCommand>();
38
+ for (const [scope, dir] of [
39
+ ['repo', join(repo, '.salaam', 'commands')],
40
+ ['user', userDir],
41
+ ] as const) {
42
+ let root: string;
43
+ try {
44
+ root = realpathSync(dir);
45
+ } catch {
46
+ continue;
47
+ }
48
+ // A repository's command folder must itself stay inside the repository.
49
+ if (scope === 'repo' && !inside(realpathSync(repo), root)) {
50
+ problems.push(`${dir}: resolves outside the repository; ignored`);
51
+ continue;
52
+ }
53
+ for (const file of walk(root, root, problems)) {
54
+ const parts = relative(root, file).replace(/\.md$/, '').split(sep);
55
+ if (!parts.every((p) => NAME.test(p))) {
56
+ problems.push(`${file}: a name may use only letters, digits, - and _; ignored`);
57
+ continue;
58
+ }
59
+ const name = parts.join(':').toLowerCase();
60
+ if (builtIn.has(name)) {
61
+ problems.push(`${file}: /${name} is a built-in command; ignored`);
62
+ continue;
63
+ }
64
+ let text: string;
65
+ try {
66
+ const real = realpathSync(file);
67
+ if (!inside(root, real)) {
68
+ problems.push(`${file}: resolves outside its commands folder; ignored`);
69
+ continue;
70
+ }
71
+ if (lstatSync(real).size > MAX_FILE) {
72
+ problems.push(`${file}: larger than 32 KiB; ignored`);
73
+ continue;
74
+ }
75
+ text = readFileSync(real, 'utf8');
76
+ } catch (error) {
77
+ problems.push(`${file}: ${(error as Error).message}`);
78
+ continue;
79
+ }
80
+ const { fields, body } = frontmatter(text);
81
+ for (const key of ['allowed-tools', 'disallowed-tools', 'hooks', 'permission-mode'])
82
+ if (key in fields)
83
+ problems.push(`${file}: ${key} is ignored; a command only writes a prompt`);
84
+ found.set(name, {
85
+ name,
86
+ scope,
87
+ path: file,
88
+ description: fields.description || `Custom ${scope} command`,
89
+ ...(fields['argument-hint'] ? { argumentHint: fields['argument-hint'] } : {}),
90
+ ...(fields.model ? { model: fields.model } : {}),
91
+ body: body.trim(),
92
+ });
93
+ }
94
+ }
95
+ return { commands: [...found.values()].sort((a, b) => a.name.localeCompare(b.name)), problems };
96
+ }
97
+
98
+ function inside(root: string, path: string): boolean {
99
+ const r = relative(root, path);
100
+ return !r.startsWith('..') && !r.startsWith(sep);
101
+ }
102
+
103
+ function walk(root: string, dir: string, problems: string[], depth = 0): string[] {
104
+ const out: string[] = [];
105
+ if (depth > 3) return out;
106
+ let entries;
107
+ try {
108
+ entries = readdirSync(dir, { withFileTypes: true });
109
+ } catch {
110
+ return out;
111
+ }
112
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
113
+ if (out.length >= MAX_FILES) break;
114
+ const path = join(dir, e.name);
115
+ if (e.isSymbolicLink()) {
116
+ let real: string;
117
+ try {
118
+ real = realpathSync(path);
119
+ } catch {
120
+ continue;
121
+ }
122
+ // Followed only to a file; whether it stays inside is checked when the file is read.
123
+ if (e.name.endsWith('.md') && lstatSync(real).isFile()) out.push(path);
124
+ } else if (e.isDirectory()) out.push(...walk(root, path, problems, depth + 1));
125
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(path);
126
+ }
127
+ return out.slice(0, MAX_FILES);
128
+ }
129
+
130
+ function frontmatter(text: string): { fields: Record<string, string>; body: string } {
131
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
132
+ if (!m) return { fields: {}, body: text };
133
+ const fields: Record<string, string> = {};
134
+ for (const line of m[1]!.split(/\r?\n/)) {
135
+ const kv = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line);
136
+ if (kv) fields[kv[1]!.toLowerCase()] = kv[2]!.trim().replace(/^(["'])(.*)\1$/, '$2');
137
+ }
138
+ return { fields, body: text.slice(m[0].length) };
139
+ }
140
+
141
+ /** Shell-like splitting: whitespace separates, double or single quotes group. */
142
+ export function splitArguments(args: string): string[] {
143
+ const out: string[] = [];
144
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
145
+ for (let m = re.exec(args); m; m = re.exec(args)) out.push(m[1] ?? m[2] ?? m[3]!);
146
+ return out;
147
+ }
148
+
149
+ /** Placeholders as in Claude Code: `$ARGUMENTS`, `$ARGUMENTS[N]` and `$N` (0-based); `\$` is a
150
+ * literal dollar. With no placeholder the arguments are appended, so they are never dropped. */
151
+ export function expandCustomCommand(command: CustomCommand, args: string): string {
152
+ const list = splitArguments(args);
153
+ let used = false;
154
+ const body = command.body.replace(
155
+ /\\\$|\$ARGUMENTS\[(\d+)\]|\$ARGUMENTS|\$(\d+)/g,
156
+ (all: string, index?: string, short?: string) => {
157
+ if (all === '\\$') return '$';
158
+ used = true;
159
+ if (all === '$ARGUMENTS') return args.trim();
160
+ return list[Number(index ?? short)] ?? '';
161
+ },
162
+ );
163
+ return !used && args.trim() ? `${body}\n\nARGUMENTS: ${args.trim()}` : body;
164
+ }
165
+
166
+ /** A typed `/name args` line that names a custom command, or null. */
167
+ export function matchCustomCommand(
168
+ text: string,
169
+ commands: readonly CustomCommand[],
170
+ ): { command: CustomCommand; args: string } | null {
171
+ const m = /^\/([a-z0-9][a-z0-9_:-]*)(?:[ \t]+([\s\S]*))?$/i.exec(text.trim());
172
+ if (!m) return null;
173
+ const command = commands.find((c) => c.name === m[1]!.toLowerCase());
174
+ return command ? { command, args: (m[2] ?? '').trim() } : null;
175
+ }