janissary 0.5.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/LICENSE +75 -0
- package/README.md +28 -0
- package/agent-names.json +52 -0
- package/bin/janus.mjs +27 -0
- package/chrome-extension/_metadata/generated_indexed_rulesets/_ruleset1 +0 -0
- package/chrome-extension/content-script.js +62 -0
- package/chrome-extension/manifest.json +25 -0
- package/chrome-extension/rules.json +16 -0
- package/dist/acp/index.js +93 -0
- package/dist/acp/loop.js +75 -0
- package/dist/acp/manager.js +126 -0
- package/dist/acp/rate-limit.js +8 -0
- package/dist/acp/runner.js +18 -0
- package/dist/acp/tools.js +27 -0
- package/dist/acp-loop.js +75 -0
- package/dist/acp-manager.js +109 -0
- package/dist/acp-runner.js +18 -0
- package/dist/acp.js +91 -0
- package/dist/agent/commands.js +29 -0
- package/dist/agent/communication-manager.js +71 -0
- package/dist/agent/state.js +58 -0
- package/dist/agent-commands.js +29 -0
- package/dist/agent-communication-manager.js +71 -0
- package/dist/agent-state.js +58 -0
- package/dist/app-themes.js +13 -0
- package/dist/browser/command.js +104 -0
- package/dist/browser/index.js +124 -0
- package/dist/browser/tab-helpers.js +51 -0
- package/dist/browser/tab.js +99 -0
- package/dist/browser-command.js +104 -0
- package/dist/browser-tab-helpers.js +51 -0
- package/dist/browser-tab.js +99 -0
- package/dist/browser.js +124 -0
- package/dist/buffer.js +42 -0
- package/dist/bus.js +52 -0
- package/dist/capture/manager.js +48 -0
- package/dist/capture/router.js +18 -0
- package/dist/capture-manager.js +48 -0
- package/dist/capture-router.js +20 -0
- package/dist/chrome-extension-loader.js +62 -0
- package/dist/cli-args.js +80 -0
- package/dist/command-manager.js +126 -0
- package/dist/command-router.js +14 -0
- package/dist/commands/acp-reset.js +13 -0
- package/dist/commands/acp.js +5 -0
- package/dist/commands/agent.js +5 -0
- package/dist/commands/broadcast.js +22 -0
- package/dist/commands/browser.js +5 -0
- package/dist/commands/clear.js +5 -0
- package/dist/commands/close.js +42 -0
- package/dist/commands/connection.js +5 -0
- package/dist/commands/db.js +5 -0
- package/dist/commands/edit.js +18 -0
- package/dist/commands/files.js +13 -0
- package/dist/commands/hist.js +7 -0
- package/dist/commands/index.js +62 -0
- package/dist/commands/monitor.js +61 -0
- package/dist/commands/msg.js +17 -0
- package/dist/commands/next.js +7 -0
- package/dist/commands/notifications.js +16 -0
- package/dist/commands/notify.js +20 -0
- package/dist/commands/open.js +41 -0
- package/dist/commands/profile.js +5 -0
- package/dist/commands/queue.js +37 -0
- package/dist/commands/quit.js +6 -0
- package/dist/commands/rename.js +12 -0
- package/dist/commands/resolve-target.js +12 -0
- package/dist/commands/schedule.js +74 -0
- package/dist/commands/search.js +26 -0
- package/dist/commands/send.js +51 -0
- package/dist/commands/state.js +9 -0
- package/dist/commands/syntax.js +36 -0
- package/dist/commands/tasks.js +7 -0
- package/dist/commands/theme.js +41 -0
- package/dist/commands/types.js +1 -0
- package/dist/commands.js +70 -0
- package/dist/completion/browser.js +24 -0
- package/dist/completion/fs.js +26 -0
- package/dist/completion/handlers.js +81 -0
- package/dist/completion/helpers.js +43 -0
- package/dist/completion/index.js +43 -0
- package/dist/completion-browser.js +24 -0
- package/dist/completion-fs.js +26 -0
- package/dist/completion-handlers.js +69 -0
- package/dist/completion-helpers.js +43 -0
- package/dist/completion.js +42 -0
- package/dist/config.js +66 -0
- package/dist/connection/close.js +41 -0
- package/dist/connection/manager.js +93 -0
- package/dist/connection/parsing.js +28 -0
- package/dist/connection-close.js +41 -0
- package/dist/connection-manager.js +93 -0
- package/dist/connection-parsing.js +28 -0
- package/dist/connections.js +75 -0
- package/dist/controller-events.js +34 -0
- package/dist/controller-file-tree.js +36 -0
- package/dist/controller.js +201 -0
- package/dist/database/index.js +83 -0
- package/dist/database/manager.js +67 -0
- package/dist/database/parsing.js +54 -0
- package/dist/database/query.js +41 -0
- package/dist/database-manager.js +67 -0
- package/dist/database-parsing.js +54 -0
- package/dist/database-query.js +41 -0
- package/dist/database.js +83 -0
- package/dist/datetime.js +15 -0
- package/dist/editor/save.js +27 -0
- package/dist/editor/sync.js +10 -0
- package/dist/editor/watch-manager.js +84 -0
- package/dist/editor-save.js +24 -0
- package/dist/editor-watch-manager.js +84 -0
- package/dist/file-tree-args.js +36 -0
- package/dist/file-tree-git-refresh.js +33 -0
- package/dist/file-tree-manager.js +252 -0
- package/dist/file-tree-moves.js +26 -0
- package/dist/file-tree-open.js +56 -0
- package/dist/file-tree-rebuild.js +22 -0
- package/dist/file-tree-watch.js +27 -0
- package/dist/file-tree.js +82 -0
- package/dist/git-status.js +64 -0
- package/dist/github-token.js +19 -0
- package/dist/global-history.js +48 -0
- package/dist/harness/auto-approve.js +79 -0
- package/dist/harness/busy-status.js +91 -0
- package/dist/harness/capture-file.js +28 -0
- package/dist/harness/index.js +87 -0
- package/dist/harness/manager.js +182 -0
- package/dist/harness/models.js +8 -0
- package/dist/harness/recorder.js +89 -0
- package/dist/harness/recording-file.js +25 -0
- package/dist/harness/screen.js +79 -0
- package/dist/harness-capture-file.js +28 -0
- package/dist/harness-manager.js +112 -0
- package/dist/harness-models.js +8 -0
- package/dist/harness-screen.js +72 -0
- package/dist/harness.js +54 -0
- package/dist/index.js +163 -0
- package/dist/instance-lock.js +34 -0
- package/dist/interactive.js +33 -0
- package/dist/main.js +203 -0
- package/dist/managers.js +1 -0
- package/dist/message-handler.js +134 -0
- package/dist/messaging.js +47 -0
- package/dist/monitor/acp.js +24 -0
- package/dist/monitor/ask.js +31 -0
- package/dist/monitor/context.js +27 -0
- package/dist/monitor/editor-feed.js +49 -0
- package/dist/monitor/feed-diff.js +22 -0
- package/dist/monitor/feeds.js +19 -0
- package/dist/monitor/framing.js +29 -0
- package/dist/monitor/harness-feed.js +21 -0
- package/dist/monitor/info.js +29 -0
- package/dist/monitor/manager.js +221 -0
- package/dist/monitor/page-feed.js +21 -0
- package/dist/monitor/parsing.js +82 -0
- package/dist/monitor/reply.js +11 -0
- package/dist/monitor/session.js +36 -0
- package/dist/monitor/stop.js +33 -0
- package/dist/monitor/suggestion.js +12 -0
- package/dist/monitor/targets.js +69 -0
- package/dist/monitor/window.js +109 -0
- package/dist/monitor-acp.js +22 -0
- package/dist/monitor-info.js +18 -0
- package/dist/monitor-manager.js +233 -0
- package/dist/monitor-parsing.js +68 -0
- package/dist/monitor-session.js +19 -0
- package/dist/monitor-targets.js +37 -0
- package/dist/monitor-window.js +81 -0
- package/dist/notifications-tab.js +32 -0
- package/dist/notifications.js +87 -0
- package/dist/open-file-manager.js +100 -0
- package/dist/openers/editor.js +41 -0
- package/dist/openers/external-viewer.js +11 -0
- package/dist/openers/image.js +27 -0
- package/dist/openers/index.js +16 -0
- package/dist/openers/markdown.js +20 -0
- package/dist/openers/os-open.js +24 -0
- package/dist/openers/page.js +51 -0
- package/dist/openers/size.js +13 -0
- package/dist/openers/types.js +1 -0
- package/dist/page/sync.js +10 -0
- package/dist/paths.js +45 -0
- package/dist/persona-parsing.js +49 -0
- package/dist/personas.js +44 -0
- package/dist/profile/agent-opener.js +154 -0
- package/dist/profile/files.js +17 -0
- package/dist/profile/manager.js +72 -0
- package/dist/profile/monitors.js +23 -0
- package/dist/profile/notifications.js +11 -0
- package/dist/profile-agent-opener.js +136 -0
- package/dist/profile-manager.js +72 -0
- package/dist/profiles.js +143 -0
- package/dist/protocol.js +1 -0
- package/dist/pseudoterminal-manager.js +106 -0
- package/dist/pty.js +41 -0
- package/dist/recognizers/acp.js +38 -0
- package/dist/recognizers/analyze.js +49 -0
- package/dist/recognizers/bash.js +51 -0
- package/dist/recognizers/db.js +26 -0
- package/dist/recognizers/index.js +1 -0
- package/dist/recognizers/lexicon.js +11 -0
- package/dist/recognizers/types.js +5 -0
- package/dist/resolve.js +39 -0
- package/dist/route-choice.js +11 -0
- package/dist/sandbox/index.js +217 -0
- package/dist/sandbox/paths.js +178 -0
- package/dist/sandbox/profile.js +161 -0
- package/dist/sandbox-paths.js +159 -0
- package/dist/sandbox-profile.js +161 -0
- package/dist/sandbox.js +217 -0
- package/dist/schedule/display.js +19 -0
- package/dist/schedule/helpers.js +76 -0
- package/dist/schedule/index.js +76 -0
- package/dist/schedule/manager.js +101 -0
- package/dist/schedule/parsing.js +63 -0
- package/dist/schedule/time.js +34 -0
- package/dist/schedule-display.js +19 -0
- package/dist/schedule-helpers.js +76 -0
- package/dist/schedule-manager.js +98 -0
- package/dist/schedule-parsing.js +63 -0
- package/dist/schedule-time.js +34 -0
- package/dist/schedule.js +76 -0
- package/dist/search-matches.js +40 -0
- package/dist/security.js +44 -0
- package/dist/shell-manager.js +139 -0
- package/dist/shell.js +59 -0
- package/dist/ssh-manager.js +42 -0
- package/dist/ssh.js +39 -0
- package/dist/startup-errors.js +21 -0
- package/dist/state-event.js +14 -0
- package/dist/state-format.js +38 -0
- package/dist/syntax-themes.js +14 -0
- package/dist/tab/agent-state.js +19 -0
- package/dist/tab/cleanup.js +33 -0
- package/dist/tab/colors.js +43 -0
- package/dist/tab/creators.js +95 -0
- package/dist/tab/dock.js +35 -0
- package/dist/tab/focus-history.js +41 -0
- package/dist/tab/formatting-handlers.js +77 -0
- package/dist/tab/formatting.js +21 -0
- package/dist/tab/history.js +14 -0
- package/dist/tab/index.js +65 -0
- package/dist/tab/manager.js +329 -0
- package/dist/tab/openers.js +35 -0
- package/dist/tab/queue.js +33 -0
- package/dist/tab/rehydrate.js +15 -0
- package/dist/tab/reorder.js +15 -0
- package/dist/tab/transcript.js +15 -0
- package/dist/tab/utils.js +55 -0
- package/dist/tab/view.js +42 -0
- package/dist/tab-cleanup.js +27 -0
- package/dist/tab-colors.js +43 -0
- package/dist/tab-creators.js +85 -0
- package/dist/tab-formatting-handlers.js +77 -0
- package/dist/tab-formatting.js +21 -0
- package/dist/tab-manager.js +418 -0
- package/dist/tab-utils.js +55 -0
- package/dist/tab.js +57 -0
- package/dist/tasks.js +32 -0
- package/dist/transcript/logger.js +36 -0
- package/dist/transcript/store.js +66 -0
- package/dist/types.js +6 -0
- package/dist/user-agent.js +65 -0
- package/dist/word-wrapping.js +42 -0
- package/dist/workspace-manager.js +40 -0
- package/dist/workspace.js +120 -0
- package/help.md +97 -0
- package/package.json +138 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { bashRecognizer } from './bash.js';
|
|
2
|
+
import { dbRecognizer as databaseRecognizer } from './db.js';
|
|
3
|
+
import { acpRecognizer } from './acp.js';
|
|
4
|
+
export const recognizers = [bashRecognizer, databaseRecognizer, acpRecognizer];
|
|
5
|
+
// A command is auto-dispatched only when the best match is at least this reliable and clearly
|
|
6
|
+
// ahead of the runner-up; otherwise the user is asked to choose.
|
|
7
|
+
export const HIGH_RELIABILITY = 0.7;
|
|
8
|
+
const DOMINANCE_MARGIN = 0.15;
|
|
9
|
+
// Poll every recognizer and decide how to route the command. The highest-reliability match wins
|
|
10
|
+
// when it is confident enough; otherwise the result is ambiguous and the caller should prompt.
|
|
11
|
+
export function analyzeCommand(command, context) {
|
|
12
|
+
const matches = recognizers
|
|
13
|
+
.map((r) => ({ route: r.route, ...r.recognize(command, context) }))
|
|
14
|
+
.filter((r) => r.match)
|
|
15
|
+
.map(({ route, reliability }) => ({ route, reliability }))
|
|
16
|
+
.toSorted((a, b) => b.reliability - a.reliability);
|
|
17
|
+
const top = matches[0];
|
|
18
|
+
const runnerUp = matches[1];
|
|
19
|
+
const isConfident = top !== undefined &&
|
|
20
|
+
top.reliability >= HIGH_RELIABILITY &&
|
|
21
|
+
(runnerUp === undefined || top.reliability - runnerUp.reliability >= DOMINANCE_MARGIN);
|
|
22
|
+
if (isConfident)
|
|
23
|
+
return { kind: 'route', route: top.route, reliability: top.reliability };
|
|
24
|
+
return { kind: 'ambiguous', candidates: matches };
|
|
25
|
+
}
|
|
26
|
+
// The routes a user may pick from in the chooser. Always offers shell and acp; offers a db
|
|
27
|
+
// choice per open connection (the query needs a concrete database to target).
|
|
28
|
+
export function routeChoices(openDbs) {
|
|
29
|
+
const choices = [{ label: 'shell', route: 'shell' }];
|
|
30
|
+
for (const database of openDbs)
|
|
31
|
+
choices.push({ label: `db query → ${database}`, route: 'db', dbName: database });
|
|
32
|
+
choices.push({ label: 'acp (agent prompt)', route: 'acp' });
|
|
33
|
+
return choices;
|
|
34
|
+
}
|
|
35
|
+
// Rewrite a bare command into the explicit, prefixed form for `choice`'s route, so it can be
|
|
36
|
+
// dispatched through the normal command pipeline.
|
|
37
|
+
export function toPrefixedCommand(command, choice) {
|
|
38
|
+
switch (choice.route) {
|
|
39
|
+
case 'shell': {
|
|
40
|
+
return `shell ${command}`;
|
|
41
|
+
}
|
|
42
|
+
case 'acp': {
|
|
43
|
+
return `acp ${command}`;
|
|
44
|
+
}
|
|
45
|
+
case 'db': {
|
|
46
|
+
return `db sqlite query ${choice.dbName} ${command}`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { FUNCTION_WORDS } from './lexicon.js';
|
|
2
|
+
// Common executables/builtins that strongly signal a shell command when they lead the line.
|
|
3
|
+
const COMMON_COMMANDS = new Set([
|
|
4
|
+
'ls', 'cd', 'pwd', 'cat', 'echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'find', 'head',
|
|
5
|
+
'tail', 'less', 'more', 'touch', 'rm', 'rmdir', 'cp', 'mv', 'mkdir', 'chmod', 'chown', 'ln',
|
|
6
|
+
'ps', 'kill', 'top', 'htop', 'df', 'du', 'tar', 'gzip', 'gunzip', 'zip', 'unzip', 'curl',
|
|
7
|
+
'wget', 'ssh', 'scp', 'rsync', 'git', 'npm', 'npx', 'node', 'yarn', 'pnpm', 'deno', 'bun',
|
|
8
|
+
'python', 'python3', 'pip', 'pip3', 'ruby', 'gem', 'go', 'cargo', 'rustc', 'make', 'cmake',
|
|
9
|
+
'sed', 'awk', 'sort', 'uniq', 'wc', 'cut', 'tr', 'xargs', 'tee', 'date', 'whoami', 'which',
|
|
10
|
+
'env', 'export', 'unset', 'source', 'sleep', 'history', 'man', 'ping', 'brew', 'apt', 'yum',
|
|
11
|
+
'docker', 'kubectl', 'code', 'vim', 'nano', 'emacs', 'open', 'say', 'jq', 'sqlite3', 'psql',
|
|
12
|
+
'mysql', 'test', 'true', 'false', 'set', 'alias', 'jobs', 'bg', 'fg', 'nohup', 'time',
|
|
13
|
+
]);
|
|
14
|
+
// Commands that are also everyday English words, so a sentence can plausibly start with one
|
|
15
|
+
// ("find the largest file", "which file is longest"). For these, a prose-looking line is treated
|
|
16
|
+
// as a natural-language prompt rather than a shell invocation.
|
|
17
|
+
const AMBIGUOUS_COMMANDS = new Set([
|
|
18
|
+
'which', 'find', 'make', 'test', 'set', 'time', 'date', 'open', 'say', 'help', 'sort', 'do',
|
|
19
|
+
'who', 'head', 'tail', 'last', 'touch', 'kill', 'cut', 'less', 'more', 'top', 'tee',
|
|
20
|
+
]);
|
|
21
|
+
// Shell metacharacters: pipes, redirects, chaining, command substitution, env refs. A bare `*`
|
|
22
|
+
// is deliberately excluded — it is weak evidence of a shell command and collides with SQL (`SELECT *`).
|
|
23
|
+
const SHELL_OPERATORS = /[|&;<>`]|\$\(|\$\{|\$[A-Za-z_]/;
|
|
24
|
+
const PATH_LIKE = /^(\.{1,2}\/|\/|~\/)/;
|
|
25
|
+
const FLAG = /^-{1,2}[A-Za-z]/;
|
|
26
|
+
// Recognize a shell/bash command. Leading with a known command is the strongest signal; shell
|
|
27
|
+
// operators or an explicit executable path also count. A command word doubling as English
|
|
28
|
+
// ("which", "find", …) at the head of a prose sentence is discounted so it routes to the agent.
|
|
29
|
+
export const bashRecognizer = {
|
|
30
|
+
route: 'shell',
|
|
31
|
+
recognize: (command) => {
|
|
32
|
+
const trimmed = command.trim();
|
|
33
|
+
const tokens = trimmed.split(/\s+/);
|
|
34
|
+
const first = (tokens[0] ?? '').toLowerCase();
|
|
35
|
+
const proseWords = tokens.slice(1).filter((t) => FUNCTION_WORDS.has(t.toLowerCase())).length;
|
|
36
|
+
const isLooksProse = tokens.length >= 3 && proseWords >= 1;
|
|
37
|
+
let score = 0;
|
|
38
|
+
if (COMMON_COMMANDS.has(first)) {
|
|
39
|
+
score = AMBIGUOUS_COMMANDS.has(first) && isLooksProse ? 0.25 : 0.9;
|
|
40
|
+
}
|
|
41
|
+
else if (PATH_LIKE.test(first)) {
|
|
42
|
+
score = 0.85;
|
|
43
|
+
}
|
|
44
|
+
if (SHELL_OPERATORS.test(trimmed))
|
|
45
|
+
score = Math.max(score, 0.6) + 0.1;
|
|
46
|
+
if (tokens.length > 1 && FLAG.test(tokens[1]))
|
|
47
|
+
score += 0.05;
|
|
48
|
+
score = Math.min(score, 0.98);
|
|
49
|
+
return { match: score >= 0.5, reliability: score };
|
|
50
|
+
},
|
|
51
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Statement keywords that, when they lead the line, mark a SQL query.
|
|
2
|
+
const SQL_START = new Set([
|
|
3
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'DROP', 'ALTER', 'WITH', 'PRAGMA',
|
|
4
|
+
'EXPLAIN', 'VACUUM', 'ATTACH', 'DETACH', 'REPLACE', 'BEGIN', 'COMMIT', 'ROLLBACK', 'TRUNCATE',
|
|
5
|
+
]);
|
|
6
|
+
// Secondary keywords that reinforce a SQL match when present anywhere in the statement.
|
|
7
|
+
const SQL_KEYWORDS = ['FROM', 'WHERE', 'JOIN', 'INTO', 'VALUES', 'SET', 'TABLE', 'GROUP BY', 'ORDER BY', 'LIMIT'];
|
|
8
|
+
// Recognize a SQL/db query. Gated on the tab having an open database connection — without one
|
|
9
|
+
// there is nothing to run the query against, so the route is not offered (per design).
|
|
10
|
+
export const dbRecognizer = {
|
|
11
|
+
route: 'db',
|
|
12
|
+
recognize: (command, context) => {
|
|
13
|
+
if (context.openDbs.length === 0)
|
|
14
|
+
return { match: false, reliability: 0 };
|
|
15
|
+
const trimmed = command.trim();
|
|
16
|
+
const first = /^([A-Za-z]+)\b/.exec(trimmed)?.[1]?.toUpperCase();
|
|
17
|
+
let score = 0;
|
|
18
|
+
score = first && SQL_START.has(first) ? 0.8 : score;
|
|
19
|
+
const hits = SQL_KEYWORDS.filter((k) => new RegExp(String.raw `\b${k.replace(' ', String.raw `\s+`)}\b`, 'i').test(trimmed)).length;
|
|
20
|
+
score += Math.min(0.18, hits * 0.09);
|
|
21
|
+
if (/;\s*$/.test(trimmed))
|
|
22
|
+
score += 0.05;
|
|
23
|
+
score = Math.min(score, 0.98);
|
|
24
|
+
return { match: score >= 0.5, reliability: score };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { analyzeCommand, routeChoices, toPrefixedCommand } from './analyze.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Grammatical "function words" (articles, copulas, auxiliaries, pronouns, prepositions,
|
|
2
|
+
// conjunctions, interrogatives). They pepper natural-language sentences but rarely appear,
|
|
3
|
+
// unquoted, in a shell invocation. Recognizers use their presence to tell a prose request
|
|
4
|
+
// ("find the largest file") apart from a command ("find . -name x").
|
|
5
|
+
export const FUNCTION_WORDS = new Set([
|
|
6
|
+
'a', 'an', 'the', 'is', 'are', 'was', 'were', 'am', 'be', 'been', 'being', 'of', 'to', 'for',
|
|
7
|
+
'and', 'or', 'but', 'that', 'this', 'these', 'those', 'with', 'from', 'into', 'my', 'your',
|
|
8
|
+
'his', 'her', 'their', 'our', 'it', 'its', 'you', 'he', 'she', 'they', 'we', 'do', 'does',
|
|
9
|
+
'did', 'should', 'would', 'could', 'can', 'will', 'than', 'then', 'what', 'why', 'how', 'who',
|
|
10
|
+
'whom', 'whose', 'when', 'where', 'which',
|
|
11
|
+
]);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A command typed without a prefix may still be a shell command, a SQL/db query, or an agent
|
|
2
|
+
// (acp) prompt. Each route has a "command recognizer" that reads the raw command and reports
|
|
3
|
+
// whether it looks like that route and how reliable the guess is. The analysis module
|
|
4
|
+
// (`analyze.ts`) polls every recognizer and routes by the most reliable match.
|
|
5
|
+
export {};
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getOutput } from './commands.js';
|
|
2
|
+
import { commands } from './commands/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Classify a prompt input into the action it represents, mirroring the dispatch order
|
|
5
|
+
* used when a command is typed into a tab. Pure: callers decide the side effects (a
|
|
6
|
+
* foreground tab can run interactive/app commands; a remote agent refuses them).
|
|
7
|
+
*
|
|
8
|
+
* - `shell`: run in a shell. Explicitly requested via a leading `shell ` keyword — there
|
|
9
|
+
* is no bare auto-run, so a non-built-in typed without the keyword is unknown.
|
|
10
|
+
* - `app`: an application built-in that needs live state.
|
|
11
|
+
* - `output`: a built-in with textual output to display (also the "unknown command" reply).
|
|
12
|
+
* - `empty`: nothing to do.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveCommand(raw) {
|
|
15
|
+
const trimmed = raw.trim();
|
|
16
|
+
if (!trimmed)
|
|
17
|
+
return { kind: 'empty' };
|
|
18
|
+
// Shell commands are launched with the `shell` keyword, which is stripped before the
|
|
19
|
+
// command reaches the shell.
|
|
20
|
+
if (/^shell\b/i.test(trimmed)) {
|
|
21
|
+
return { kind: 'shell', cmd: trimmed.replace(/^shell\b\s*/i, '') };
|
|
22
|
+
}
|
|
23
|
+
const command = trimmed.replace(/^\//, '');
|
|
24
|
+
for (const c of commands) {
|
|
25
|
+
if (c.match(command)) {
|
|
26
|
+
return { kind: 'app', name: c.name, cmd: command };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const output = getOutput(command);
|
|
30
|
+
if (output !== null) {
|
|
31
|
+
// `getOutput` returns the "Unknown command: ..." message for anything unrecognized; surface
|
|
32
|
+
// that as `unknown` so the interactive dispatcher can run command recognition on it.
|
|
33
|
+
const kind = output.startsWith('Unknown command:') ? 'unknown' : 'output';
|
|
34
|
+
return { kind, cmd: command, output };
|
|
35
|
+
}
|
|
36
|
+
// Shell commands require the `shell` keyword (handled above); a bare non-built-in is
|
|
37
|
+
// reported as unknown rather than auto-run in the shell.
|
|
38
|
+
return { kind: 'empty' };
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { analyzeCommand } from './recognizers/index.js';
|
|
2
|
+
// Whether `input` resolves to a route (and if so, which one). A bare 'db' target auto-resolves
|
|
3
|
+
// when exactly one db is open — the caller only needs to prompt when there's a real ambiguity.
|
|
4
|
+
export function resolveRouteChoice(input, openDbs) {
|
|
5
|
+
const decision = analyzeCommand(input, { openDbs });
|
|
6
|
+
if (decision.kind !== 'route' || (decision.route === 'db' && openDbs.length !== 1))
|
|
7
|
+
return undefined;
|
|
8
|
+
return decision.route === 'db'
|
|
9
|
+
? { label: '', route: 'db', dbName: openDbs[0] }
|
|
10
|
+
: { label: '', route: decision.route };
|
|
11
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { SANDBOX_PROFILE, SANDBOX_PROFILE_OFFLINE } from './profile.js';
|
|
6
|
+
import { HOME_WRITE_CARVEOUTS, HOME_READ_CARVEINS, SECRET_DENY_PATHS, HOME_READ_LISTING_DIRS, HOME_WRITE_PREFIX_CARVEOUTS, WRITE_CARVEOUT_PARAMS, READ_CARVEIN_PARAMS, SECRET_DENY_PARAMS, LISTING_DIR_PARAMS, WRITE_PREFIX_PARAMS, ENV_SCRUB_PATTERNS, } from './paths.js';
|
|
7
|
+
import { getConfig } from '../config.js';
|
|
8
|
+
let cachedAvailable;
|
|
9
|
+
// Whether Seatbelt sandboxing can be applied on this machine: darwin, with `sandbox-exec` on
|
|
10
|
+
// `PATH`. Cached after the first check.
|
|
11
|
+
export function sandboxAvailable() {
|
|
12
|
+
cachedAvailable ??= process.platform === 'darwin' && existsSync('/usr/bin/sandbox-exec');
|
|
13
|
+
return cachedAvailable;
|
|
14
|
+
}
|
|
15
|
+
// A one-line notice to append to a newly created workspaced tab's transcript when its processes
|
|
16
|
+
// will NOT actually be confined — either the config toggle is off, or `sandbox-exec` isn't
|
|
17
|
+
// available on this machine. Undefined when sandboxing is active, so callers only surface it when
|
|
18
|
+
// there's something to say.
|
|
19
|
+
export function sandboxNotice() {
|
|
20
|
+
if (!getConfig().sandboxWorkspaces)
|
|
21
|
+
return 'workspace isolation off: sandboxWorkspaces disabled in config';
|
|
22
|
+
if (!sandboxAvailable())
|
|
23
|
+
return 'workspace isolation off: sandbox-exec unavailable';
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Resolve a path through any symlinks (macOS's `/tmp` → `/private/tmp` being the common case) —
|
|
27
|
+
// Seatbelt's `subpath` rules match against the resolved path, so an unresolved path silently
|
|
28
|
+
// fails to carve in. Falls back to the input path if it doesn't exist (yet).
|
|
29
|
+
function resolvePath(p) {
|
|
30
|
+
try {
|
|
31
|
+
return realpathSync(p);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
let cachedDarwinUserCacheDir;
|
|
38
|
+
// The real per-user `/var/folders/<xx>/<hash>/C/` cache directory macOS's `confstr(3)` hands out —
|
|
39
|
+
// NOT the same as `$TMPDIR`, which `sandboxSpawn` overrides to a workspace-local path below, and
|
|
40
|
+
// NOT its `.../T/` (temp) sibling, which stays denied (that's where `os.tmpdir()`-based scratch
|
|
41
|
+
// dirs land — carving it in too would let a sandboxed process write anywhere a plain `mktemp`
|
|
42
|
+
// call resolves to, defeating the outside-the-workspace write deny). System frameworks look the
|
|
43
|
+
// cache path up directly via `confstr`, bypassing our `TMPDIR` override entirely, and write
|
|
44
|
+
// lock/cache files into it regardless (e.g. Security.framework's legacy MDS subsystem locks
|
|
45
|
+
// `.../C/mds/mds.lock` on every `SecItemCopyMatching` call — denied, the call silently fails
|
|
46
|
+
// rather than erroring, so a sandboxed harness reads back "not logged in" even with a valid
|
|
47
|
+
// Keychain item). Cached: it's fixed for the life of the host process.
|
|
48
|
+
function darwinUserCacheDir() {
|
|
49
|
+
if (cachedDarwinUserCacheDir)
|
|
50
|
+
return cachedDarwinUserCacheDir;
|
|
51
|
+
try {
|
|
52
|
+
const cacheDir = execFileSync('getconf', ['DARWIN_USER_CACHE_DIR']).toString().trim();
|
|
53
|
+
cachedDarwinUserCacheDir = resolvePath(cacheDir);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
cachedDarwinUserCacheDir = '/nonexistent-janissary-darwin-user-cache-dir-placeholder';
|
|
57
|
+
}
|
|
58
|
+
return cachedDarwinUserCacheDir;
|
|
59
|
+
}
|
|
60
|
+
let cachedClaudeScratchDir;
|
|
61
|
+
// The harness CLI's own per-user scratch tree, `/private/tmp/claude-<uid>/`, under which it creates
|
|
62
|
+
// a per-project/session scratchpad directory before running any tool — including a plain shell
|
|
63
|
+
// command. This is a fixed, UID-keyed path entirely outside `$HOME`, our workspace-local `TMPDIR`
|
|
64
|
+
// override, and the Darwin user cache dir above, so without carving it in, every tool invocation
|
|
65
|
+
// inside a sandboxed harness session fails at that housekeeping step, before the tool's own command
|
|
66
|
+
// ever runs. Cached: the UID is fixed for the life of the host process.
|
|
67
|
+
function claudeScratchDir() {
|
|
68
|
+
if (cachedClaudeScratchDir)
|
|
69
|
+
return cachedClaudeScratchDir;
|
|
70
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
|
|
71
|
+
cachedClaudeScratchDir = resolvePath(`/private/tmp/claude-${uid}`);
|
|
72
|
+
return cachedClaudeScratchDir;
|
|
73
|
+
}
|
|
74
|
+
// Walk up from a resolved executable's path to the nearest ancestor directory literally named
|
|
75
|
+
// `node_modules`, if any. A globally-installed npm package (`npm` itself included) is laid out as
|
|
76
|
+
// `.../node_modules/<pkg>/bin/<script>`, where the script requires sibling files from `<pkg>/lib/`
|
|
77
|
+
// and beyond — carving in only the immediate `bin/` directory (as for a single bundled binary like
|
|
78
|
+
// claude.exe) leaves those sibling requires denied. Carving in the whole `node_modules/` directory
|
|
79
|
+
// instead covers every globally-installed package uniformly (not just the one being run), which is
|
|
80
|
+
// harmless — it's still just $HOME-scoped code, not secrets. Falls back to the script's own
|
|
81
|
+
// directory when there's no `node_modules` ancestor (a single-file bundled binary).
|
|
82
|
+
function packageRootDir(resolvedBin) {
|
|
83
|
+
let dir = path.dirname(resolvedBin);
|
|
84
|
+
for (;;) {
|
|
85
|
+
if (path.basename(dir) === 'node_modules')
|
|
86
|
+
return dir;
|
|
87
|
+
const parent = path.dirname(dir);
|
|
88
|
+
if (parent === dir)
|
|
89
|
+
return path.dirname(resolvedBin);
|
|
90
|
+
dir = parent;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Resolve `command` to an absolute path the way `execvp`/`posix_spawn` would (a `PATH` search for a
|
|
94
|
+
// bare name, or the path itself if it already contains a separator) so its containing directory can
|
|
95
|
+
// be carved into the read allow-list. Without this, a harness binary installed under `$HOME` (e.g.
|
|
96
|
+
// via nvm, or `~/.opencode/bin`) can't read its own executable — which some system frameworks need
|
|
97
|
+
// to do internally (Keychain's `SecItemCopyMatching` calls `CFBundleGetMainBundle`, which reopens the
|
|
98
|
+
// calling process's own binary and its directory to determine code identity for ACL matching; denied,
|
|
99
|
+
// the harness looks logged out even though its Keychain item is intact). Returns both the literal
|
|
100
|
+
// (unresolved — e.g. many npm-global installs are a `bin/foo` symlink into `lib/node_modules/...`)
|
|
101
|
+
// and fully realpath-resolved directory (widened to the enclosing `node_modules/` via
|
|
102
|
+
// `packageRootDir` above, for packages — like npm — that are more than a single bundled file), same
|
|
103
|
+
// dual reasoning as `dualParams` in sandbox-profile.ts: a framework may `opendir`/`lstat` the
|
|
104
|
+
// symlink's own directory as well as the resolved target's. Falls back to a path that matches
|
|
105
|
+
// nothing so the profile's params always have a bound value.
|
|
106
|
+
function resolveExecutableDirs(command) {
|
|
107
|
+
const fallback = '/nonexistent-janissary-self-bin-placeholder';
|
|
108
|
+
const literalBin = command.includes('/')
|
|
109
|
+
? (existsSync(command) ? command : undefined)
|
|
110
|
+
: (process.env.PATH ?? '').split(':').filter(Boolean)
|
|
111
|
+
.map((dir) => path.join(dir, command))
|
|
112
|
+
.find((candidate) => existsSync(candidate));
|
|
113
|
+
if (!literalBin)
|
|
114
|
+
return { literal: fallback, real: fallback };
|
|
115
|
+
return { literal: path.dirname(literalBin), real: packageRootDir(resolvePath(literalBin)) };
|
|
116
|
+
}
|
|
117
|
+
// The directory of the Node binary currently running the janissary server itself
|
|
118
|
+
// (`process.execPath`), in both literal and realpath-resolved form (same dual reasoning as
|
|
119
|
+
// `resolveExecutableDirs` above — an nvm-managed `node` is commonly a symlink). Carved into the
|
|
120
|
+
// read allow-list so a script running inside the sandbox (e.g. a project's own `.claude/settings.json`
|
|
121
|
+
// hook) can reliably invoke a known-good `node` via the `JANISSARY_NODE` env var below, instead of
|
|
122
|
+
// hoping a bare `node` on the sandboxed process's PATH resolves to a working binary — PATH
|
|
123
|
+
// resolution order inside a spawned/hook context doesn't always match the server's own.
|
|
124
|
+
function serverNodeDirs() {
|
|
125
|
+
const execPath = process.execPath;
|
|
126
|
+
return { literal: path.dirname(execPath), real: path.dirname(resolvePath(execPath)) };
|
|
127
|
+
}
|
|
128
|
+
// The workspace clone's own git objects directory. Falls back to a parent repo's real objects
|
|
129
|
+
// directory via a `--shared` alternates file, if one is present (older, locally-shared clones) —
|
|
130
|
+
// today's independent clones of `origin` have no alternates file, so this always hits the fallback.
|
|
131
|
+
function parentGitObjectsDir(workspaceDir) {
|
|
132
|
+
const fallback = path.join(workspaceDir, '.git', 'objects');
|
|
133
|
+
try {
|
|
134
|
+
const alternatesPath = path.join(workspaceDir, '.git', 'objects', 'info', 'alternates');
|
|
135
|
+
const target = readFileSync(alternatesPath, 'utf8').trim().split('\n', 1)[0];
|
|
136
|
+
return target ? resolvePath(target) : fallback;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return fallback;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Drop credential-shaped vars and agent-socket escape vectors (see `sandbox-profile.ts`).
|
|
143
|
+
function scrubEnv(env) {
|
|
144
|
+
const scrubbed = {};
|
|
145
|
+
for (const [key, value] of Object.entries(env)) {
|
|
146
|
+
if (ENV_SCRUB_PATTERNS.some((pattern) => pattern.test(key)))
|
|
147
|
+
continue;
|
|
148
|
+
scrubbed[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return scrubbed;
|
|
151
|
+
}
|
|
152
|
+
// `-D <param>=<path>` for each home-relative table entry, in both its literal (`~/…`, as named —
|
|
153
|
+
// covers an `lstat`/`readlink` of a symlinked dotfile) and fully realpath-resolved (covers a
|
|
154
|
+
// `read`/`open` that follows the symlink) forms. See the comment on `dualParams` in
|
|
155
|
+
// sandbox-profile.ts for why both are needed.
|
|
156
|
+
function homeDParams(home, relPaths, params) {
|
|
157
|
+
const args = [];
|
|
158
|
+
for (const [i, rel] of relPaths.entries()) {
|
|
159
|
+
const literalPath = path.join(home, rel);
|
|
160
|
+
args.push('-D', `${params.literal[i]}=${literalPath}`, '-D', `${params.real[i]}=${resolvePath(literalPath)}`);
|
|
161
|
+
}
|
|
162
|
+
return args;
|
|
163
|
+
}
|
|
164
|
+
// Wrap a spawn invocation (`command` + `args` — the same shape `child_process.spawn`/node-pty's
|
|
165
|
+
// `spawn` take) for a workspaced tab. Returns the input unchanged when there's nothing to
|
|
166
|
+
// sandbox: no `workspaceDir`, the `sandboxWorkspaces` config toggle is off, or `sandbox-exec`
|
|
167
|
+
// isn't available (e.g. non-darwin). Otherwise returns `sandbox-exec -p <profile> -D … -- <command>
|
|
168
|
+
// <args>` plus a credential-scrubbed environment with `TMPDIR` set to the workspace's private
|
|
169
|
+
// temp dir.
|
|
170
|
+
export function sandboxSpawn(options, command, args, env = process.env) {
|
|
171
|
+
if (!options.workspaceDir || !getConfig().sandboxWorkspaces || !sandboxAvailable()) {
|
|
172
|
+
return { command, args, env };
|
|
173
|
+
}
|
|
174
|
+
const workspaceDir = resolvePath(options.workspaceDir);
|
|
175
|
+
const tmpDir = resolvePath(`${options.workspaceDir}.tmp`);
|
|
176
|
+
const home = resolvePath(homedir());
|
|
177
|
+
const gitObjects = parentGitObjectsDir(options.workspaceDir);
|
|
178
|
+
const selfDirs = resolveExecutableDirs(options.selfBinaryHint ?? command);
|
|
179
|
+
const darwinCacheDir = darwinUserCacheDir();
|
|
180
|
+
const scratchDir = claudeScratchDir();
|
|
181
|
+
const serverNodeDir = serverNodeDirs();
|
|
182
|
+
const scrubbed = scrubEnv(env);
|
|
183
|
+
scrubbed.TMPDIR = tmpDir;
|
|
184
|
+
scrubbed.JANISSARY_NODE = process.execPath;
|
|
185
|
+
if (options.githubToken) {
|
|
186
|
+
scrubbed.GH_TOKEN = options.githubToken;
|
|
187
|
+
// `gh` reads `~/.config/gh/hosts.yml` on every invocation regardless of `GH_TOKEN`, and its
|
|
188
|
+
// config loader treats the sandbox's EPERM deny on that file (see SECRET_DENY_PATHS in
|
|
189
|
+
// sandbox-profile.ts) as fatal, refusing to run at all. Pointing `GH_CONFIG_DIR` at an empty,
|
|
190
|
+
// workspace-private directory instead gives `gh` a genuinely absent hosts.yml (real ENOENT),
|
|
191
|
+
// which it handles by falling through to `GH_TOKEN` normally.
|
|
192
|
+
scrubbed.GH_CONFIG_DIR = path.join(tmpDir, 'gh-config');
|
|
193
|
+
}
|
|
194
|
+
const profile = options.offline ? SANDBOX_PROFILE_OFFLINE : SANDBOX_PROFILE;
|
|
195
|
+
const dParams = [
|
|
196
|
+
'-D', `WORKSPACE=${workspaceDir}`,
|
|
197
|
+
'-D', `TMPDIR=${tmpDir}`,
|
|
198
|
+
'-D', `HOME=${home}`,
|
|
199
|
+
'-D', `GIT_OBJECTS=${gitObjects}`,
|
|
200
|
+
'-D', `SELF_DIR_L=${selfDirs.literal}`,
|
|
201
|
+
'-D', `SELF_DIR_R=${selfDirs.real}`,
|
|
202
|
+
'-D', `DARWIN_USER_CACHE_DIR=${darwinCacheDir}`,
|
|
203
|
+
'-D', `CLAUDE_SCRATCH_DIR=${scratchDir}`,
|
|
204
|
+
'-D', `SERVER_NODE_DIR_L=${serverNodeDir.literal}`,
|
|
205
|
+
'-D', `SERVER_NODE_DIR_R=${serverNodeDir.real}`,
|
|
206
|
+
...homeDParams(home, HOME_WRITE_CARVEOUTS, WRITE_CARVEOUT_PARAMS),
|
|
207
|
+
...homeDParams(home, HOME_READ_CARVEINS, READ_CARVEIN_PARAMS),
|
|
208
|
+
...homeDParams(home, SECRET_DENY_PATHS, SECRET_DENY_PARAMS),
|
|
209
|
+
...homeDParams(home, HOME_READ_LISTING_DIRS, LISTING_DIR_PARAMS),
|
|
210
|
+
...homeDParams(home, HOME_WRITE_PREFIX_CARVEOUTS, WRITE_PREFIX_PARAMS),
|
|
211
|
+
];
|
|
212
|
+
return {
|
|
213
|
+
command: 'sandbox-exec',
|
|
214
|
+
args: ['-p', profile, ...dParams, '--', command, ...args],
|
|
215
|
+
env: scrubbed,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Table-driven path/env lists the Seatbelt profile in sandbox-profile.ts is built from.
|
|
2
|
+
// Extending any restriction is a one-line table change here.
|
|
3
|
+
// Narrow write carve-outs: harness auth/state directories and package-manager cache subpaths —
|
|
4
|
+
// never the whole `~/.claude`, `~/.cache`, or `~/.npm` (broad cache write access would let a
|
|
5
|
+
// sandboxed agent poison packages/plugins that other, non-sandboxed processes later consume).
|
|
6
|
+
// The `.claude/` entries are the session-state paths a sandboxed `claude` writes while running:
|
|
7
|
+
// transcripts, session env, task/todo/job state, shell snapshots, edit history, debug logs,
|
|
8
|
+
// plans, paste cache, and its stats/statsig telemetry caches. Deliberately absent: `.claude/plugins`
|
|
9
|
+
// (write access would let a sandboxed agent replace plugin code the unsandboxed CLI later runs —
|
|
10
|
+
// reads are carved in below), `daemon`/`ide`/`backups`, and the auto-updater's state files.
|
|
11
|
+
export const HOME_WRITE_CARVEOUTS = [
|
|
12
|
+
'.claude/projects',
|
|
13
|
+
'.claude/session-env',
|
|
14
|
+
'.claude/sessions',
|
|
15
|
+
'.claude/tasks',
|
|
16
|
+
'.claude/todos',
|
|
17
|
+
'.claude/jobs',
|
|
18
|
+
'.claude/shell-snapshots',
|
|
19
|
+
'.claude/file-history',
|
|
20
|
+
'.claude/history.jsonl',
|
|
21
|
+
'.claude/debug',
|
|
22
|
+
'.claude/plans',
|
|
23
|
+
'.claude/paste-cache',
|
|
24
|
+
'.claude/cache',
|
|
25
|
+
'.claude/statsig',
|
|
26
|
+
'.claude/stats-cache.json',
|
|
27
|
+
'.claude/mcp-needs-auth-cache.json',
|
|
28
|
+
'.claude.json',
|
|
29
|
+
'.codex',
|
|
30
|
+
'.config/opencode',
|
|
31
|
+
'.local/share/opencode',
|
|
32
|
+
'.local/state/opencode',
|
|
33
|
+
'.npm/_cacache',
|
|
34
|
+
'.npm/_logs',
|
|
35
|
+
'.npm/_npx',
|
|
36
|
+
'.cache/pip',
|
|
37
|
+
'.cache/yarn',
|
|
38
|
+
];
|
|
39
|
+
// Read carve-ins: the write carve-outs (a harness needs to read its own state), plus read-only
|
|
40
|
+
// extras — `.claude/settings.json` and the customization dirs a sandboxed `claude` loads on
|
|
41
|
+
// startup but must not modify (`plugins`, `skills`, `agents`, `commands`, `keybindings.json`),
|
|
42
|
+
// `.gitconfig` and `.gitexcludes` (the latter is whatever `core.excludesfile`
|
|
43
|
+
// in the former points to; every git invocation reads both), `.config/gh/config.yml` (`gh`'s
|
|
44
|
+
// general settings — git_protocol, editor, prompt — as opposed to `hosts.yml`, which can hold a
|
|
45
|
+
// plaintext OAuth token and stays denied via `SECRET_DENY_PATHS`; a workspaced `gh` authenticates
|
|
46
|
+
// via the injected `GH_TOKEN` env var instead, see `github-token.ts`), `Library/Keychains` (see
|
|
47
|
+
// the comment on `SECRET_DENY_PATHS` — read access is needed for any Keychain lookup to work at all
|
|
48
|
+
// on this OS, including harness login), `.nvm` (nvm's own `nvm.sh`/`bash_completion` loader
|
|
49
|
+
// scripts plus every installed Node version's binaries and libs under `versions/` — broader than
|
|
50
|
+
// SERVER_NODE_DIR_L/R in sandbox.ts, which only carves in the one version the janissary server
|
|
51
|
+
// itself runs on; a sandboxed process that sources `nvm.sh` or shells out to a bare `node`/`npm`/
|
|
52
|
+
// `npx`, or to a harness installed under a different nvm-managed version, needs to read the whole
|
|
53
|
+
// tree. Execute access for these binaries doesn't need a separate carve-in — `process-exec` is
|
|
54
|
+
// allowed everywhere except `/tmp`/`/private/tmp`, so this read carve-in is the only gap), `.rvm`
|
|
55
|
+
// (same reasoning as `.nvm`, for Ruby: `rvm`'s own loader script plus every installed Ruby
|
|
56
|
+
// version's binaries and gems under `rubies/`/`gems/`), and `.bash_profile`/`.bashrc` (a
|
|
57
|
+
// login/interactive bash shell sources these on startup; without the carve-in, spawning
|
|
58
|
+
// `bash -lc` inside the sandbox fails that read silently and the shell starts with none of the
|
|
59
|
+
// user's aliases/functions/PATH additions).
|
|
60
|
+
export const HOME_READ_CARVEINS = [
|
|
61
|
+
...HOME_WRITE_CARVEOUTS,
|
|
62
|
+
'.claude/settings.json', '.claude/plugins', '.claude/skills', '.claude/agents', '.claude/commands', '.claude/keybindings.json',
|
|
63
|
+
'.gitconfig', '.gitexcludes', '.config/gh/config.yml', 'Library/Keychains', '.nvm', '.rvm',
|
|
64
|
+
'.bash_profile', '.bashrc',
|
|
65
|
+
];
|
|
66
|
+
// Directories where `opendir`/`readdir` must work even though most of their *contents* aren't
|
|
67
|
+
// separately carved in. `.claude` itself: a settings read-modify-write (e.g. `claude --effort`, or
|
|
68
|
+
// any tool following this project's own "read raw settings before writing" convention) opens the
|
|
69
|
+
// directory node itself to enumerate settings files before touching `settings.json` — a plain
|
|
70
|
+
// `subpath` carve-in of the file alone only covers `open()`ing that one path, not the parent
|
|
71
|
+
// directory's `file-read-data` (Seatbelt's listing operation, distinct from the `file-read-metadata`
|
|
72
|
+
// stat access already allowed HOME-wide — see the deny-then-carve-in comment in sandbox-profile.ts).
|
|
73
|
+
// These use `literal`, not `subpath`: the directory node itself becomes listable, but files inside
|
|
74
|
+
// it stay gated by the specific carve-ins in HOME_READ_CARVEINS above (or stay denied).
|
|
75
|
+
export const HOME_READ_LISTING_DIRS = ['.claude'];
|
|
76
|
+
// Write carve-outs matched by Seatbelt `prefix` (string prefix on the full path) rather than
|
|
77
|
+
// `subpath` (path-component match) or `literal` (exact match). `.claude/settings.json` itself is
|
|
78
|
+
// deliberately absent from HOME_WRITE_CARVEOUTS above — but a settings read-modify-write (e.g.
|
|
79
|
+
// `claude --effort`) writes it via an atomic-rename pattern: create a sibling temp file
|
|
80
|
+
// (`settings.json.tmp.<pid>.<random>`, a fresh, unpredictable name every write), fsync it, then
|
|
81
|
+
// rename it over the target. `subpath`/`literal` can't carve in a name that doesn't exist yet and
|
|
82
|
+
// varies per write; `prefix` matches both the base filename and every `<base>.tmp.*` sibling
|
|
83
|
+
// because the base string is a literal character-prefix of each. Narrower than carving in the
|
|
84
|
+
// whole `.claude` directory for writes (still deliberately avoided, per the comment on
|
|
85
|
+
// HOME_WRITE_CARVEOUTS above) — this only ever matches paths starting with the exact
|
|
86
|
+
// `settings.json` string.
|
|
87
|
+
//
|
|
88
|
+
// `Library/Keychains/login.keychain-db` is here for the same atomic-write reason, and is the fix
|
|
89
|
+
// for a specific failure mode: OAuth-based harness auth (e.g. Claude Code) reads its credential
|
|
90
|
+
// from the login Keychain fine — reads are carved in via HOME_READ_CARVEINS — but when the access
|
|
91
|
+
// token expires mid-session, persisting the *refreshed* token back is a Keychain write, which hit
|
|
92
|
+
// the top-level deny-default and silently failed, so the stale expired token kept being sent and
|
|
93
|
+
// the provider returned 401 invalid-credentials. The write lands as an atomic-rename temp sibling
|
|
94
|
+
// `login.keychain-db.sb-<random>` (confirmed in the Sandbox deny log: `file-write-create
|
|
95
|
+
// .../login.keychain-db.sb-151e5e06-tFbiQ8`), so a `subpath`/`literal` carve-in of the base name
|
|
96
|
+
// can't cover it — `prefix` does, and also naturally covers the DB's SQLite sidecars
|
|
97
|
+
// (`login.keychain-db-wal`/`-shm`/`-journal`), all of which share the base as a character-prefix.
|
|
98
|
+
// Deliberately the login keychain DB only, not the whole `~/Library/Keychains` subtree: the DB
|
|
99
|
+
// stays encrypted and per-item ACL-enforced by securityd regardless of raw file writability (same
|
|
100
|
+
// argument the read carve-in rests on — see SECRET_DENY_PATHS), and the narrow prefix keeps a
|
|
101
|
+
// sandboxed process from tampering with unrelated keychain files. If a data-protection keychain
|
|
102
|
+
// (`<UUID>/keychain-2.db`) is ever in play, its own write deny will surface separately in the log.
|
|
103
|
+
export const HOME_WRITE_PREFIX_CARVEOUTS = ['.claude/settings.json', 'Library/Keychains/login.keychain-db'];
|
|
104
|
+
// Secret paths denied last, even inside a carve-in.
|
|
105
|
+
//
|
|
106
|
+
// `Library/Keychains` is deliberately NOT here: reads are carved in (HOME_READ_CARVEINS) and one
|
|
107
|
+
// narrow write is carved in (HOME_WRITE_PREFIX_CARVEOUTS covers `login.keychain-db` and its
|
|
108
|
+
// atomic-write/SQLite siblings, so a harness can persist a refreshed OAuth token — see that
|
|
109
|
+
// comment); every other write under the subtree stays denied by the top-level deny-default. Even
|
|
110
|
+
// "modern" Keychain Services calls (SecItemCopyMatching) fall through to
|
|
111
|
+
// a legacy CDSA/MDS implementation on this OS that reads the keychain DB file directly rather than
|
|
112
|
+
// only talking to securityd over IPC; denying that read blocks every keychain lookup a sandboxed
|
|
113
|
+
// harness makes, including its own OAuth credential, and it shows up as "not logged in" rather
|
|
114
|
+
// than a permission error. The DB stays encrypted and per-item ACL-enforced by securityd regardless
|
|
115
|
+
// of raw file readability, so this doesn't hand out plaintext secrets — it's a materially larger
|
|
116
|
+
// read surface than the other entries here, but the alternative breaks harness login outright.
|
|
117
|
+
//
|
|
118
|
+
// `.config/gh/hosts.yml`'s deny below now reads as ENOENT too (see the errno qualifier on the
|
|
119
|
+
// final deny rule in buildProfile), which is exactly what `gh` needs — but `sandbox.ts` still
|
|
120
|
+
// separately points `GH_CONFIG_DIR` at an empty, workspace-writable directory whenever a scoped
|
|
121
|
+
// token is injected, so `gh` finds a real, uncomplicated absent `hosts.yml` there rather than
|
|
122
|
+
// depending on this deny rule's errno behavior. Kept as defense in depth: `gh`'s Go config loader
|
|
123
|
+
// treats any read error there as fatal, so relying on a single mechanism to keep that read from
|
|
124
|
+
// ever mattering felt worth avoiding.
|
|
125
|
+
export const SECRET_DENY_PATHS = [
|
|
126
|
+
// On installs without Keychain access (Linux, some containers) this file holds the harness
|
|
127
|
+
// OAuth token in plaintext. Already outside every carve-in above, but denied explicitly so a
|
|
128
|
+
// future widening of the `.claude/` entries can't silently expose it.
|
|
129
|
+
'.claude/.credentials.json',
|
|
130
|
+
'.ssh', '.aws', '.gnupg', '.kube', '.netrc', '.config/gh/hosts.yml', '.docker',
|
|
131
|
+
'.config/gcloud', '.azure', '.cargo/credentials', '.cargo/credentials.toml',
|
|
132
|
+
'.pypirc', '.m2/settings.xml', '.terraform.d',
|
|
133
|
+
'.bash_history', '.zsh_history', '.python_history', '.node_repl_history',
|
|
134
|
+
'Library/Application Support/Google/Chrome',
|
|
135
|
+
'Library/Application Support/Firefox',
|
|
136
|
+
'Library/Application Support/BraveSoftware',
|
|
137
|
+
'Library/Safari',
|
|
138
|
+
];
|
|
139
|
+
// Env vars scrubbed from a workspaced process's environment: credential-shaped vars and
|
|
140
|
+
// agent-socket / credential-helper escape vectors that bypass the file-read denies above (e.g.
|
|
141
|
+
// `SSH_AUTH_SOCK` lets a process use the user's SSH keys without ever reading `~/.ssh`). LLM
|
|
142
|
+
// provider keys (`ANTHROPIC_*`, `OPENAI_*`, `GEMINI_*`/`GOOGLE_*`) are deliberately NOT matched —
|
|
143
|
+
// the harnesses and the ACP agent need their own credentials to function.
|
|
144
|
+
export const ENV_SCRUB_PATTERNS = [
|
|
145
|
+
/^AWS_/, /^GITHUB_TOKEN$/, /^GH_TOKEN$/, /^NPM_TOKEN$/, /^DOCKER_/, /^KUBECONFIG$/,
|
|
146
|
+
/_SECRET$/, /_PASSWORD$/,
|
|
147
|
+
/^SSH_AUTH_SOCK$/, /^GPG_AGENT_INFO$/, /^GNUPGHOME$/,
|
|
148
|
+
/^GIT_ASKPASS$/, /^GIT_CREDENTIAL_HELPER$/, /^KRB5CCNAME$/,
|
|
149
|
+
];
|
|
150
|
+
function paramName(prefix, index) {
|
|
151
|
+
return `${prefix}${index}`;
|
|
152
|
+
}
|
|
153
|
+
// Two `-D` param names per table entry, in the same order — `sandbox.ts` pairs these with the
|
|
154
|
+
// literal (`~/…`, unresolved beyond `$HOME` itself) and fully realpath-resolved forms of the same
|
|
155
|
+
// path. Both are needed: many dotfiles (`.gitconfig`, `.npmrc`, …) are themselves symlinks (e.g.
|
|
156
|
+
// managed by a dotfile manager), and Seatbelt evaluates an `lstat`/`readlink` of the symlink node
|
|
157
|
+
// against its literal path but a `read`/`open` that follows it against the resolved target —
|
|
158
|
+
// carving in only one leaves the other operation denied.
|
|
159
|
+
export function dualParams(prefix, count) {
|
|
160
|
+
return {
|
|
161
|
+
literal: Array.from({ length: count }, (_, i) => paramName(`${prefix}L`, i)),
|
|
162
|
+
real: Array.from({ length: count }, (_, i) => paramName(`${prefix}R`, i)),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export const WRITE_CARVEOUT_PARAMS = dualParams('W', HOME_WRITE_CARVEOUTS.length);
|
|
166
|
+
export const READ_CARVEIN_PARAMS = dualParams('R', HOME_READ_CARVEINS.length);
|
|
167
|
+
export const SECRET_DENY_PARAMS = dualParams('S', SECRET_DENY_PATHS.length);
|
|
168
|
+
export const LISTING_DIR_PARAMS = dualParams('D', HOME_READ_LISTING_DIRS.length);
|
|
169
|
+
export const WRITE_PREFIX_PARAMS = dualParams('P', HOME_WRITE_PREFIX_CARVEOUTS.length);
|
|
170
|
+
export const clausesFor = (params) => [...params.literal, ...params.real].map((p) => ` (subpath (param "${p}"))`).join('\n');
|
|
171
|
+
// Same shape as `clausesFor`, but matches only the exact path (Seatbelt `literal`) — used for
|
|
172
|
+
// HOME_READ_LISTING_DIRS, where a directory node must be listable without granting recursive
|
|
173
|
+
// access to everything under it (that's what `subpath` would do).
|
|
174
|
+
export const literalClausesFor = (params) => [...params.literal, ...params.real].map((p) => ` (literal (param "${p}"))`).join('\n');
|
|
175
|
+
// Same shape again, but Seatbelt `prefix` (string-prefix match) — used for
|
|
176
|
+
// HOME_WRITE_PREFIX_CARVEOUTS, where the writable path's exact name isn't known ahead of time
|
|
177
|
+
// (a randomly-named atomic-write temp sibling of a fixed base filename).
|
|
178
|
+
export const prefixClausesFor = (params) => [...params.literal, ...params.real].map((p) => ` (prefix (param "${p}"))`).join('\n');
|