@miphamai/cli 0.81.7 → 0.81.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +14 -9
- package/src/config/loader.ts +202 -63
- package/src/config/preferences.ts +5 -2
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +157 -6
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +49 -2
- package/src/core/session-store.ts +11 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/daemon/session-worker.ts +15 -0
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +47 -19
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +30 -8
- package/src/providers/anthropic.ts +74 -13
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +25 -2
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +17 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +146 -24
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +83 -3
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +70 -16
- package/src/tools/file/read.ts +151 -45
- package/src/tools/index.ts +12 -4
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +187 -41
- package/src/workflow/primitives/agent.ts +4 -0
- package/src/artifacts/versioning.ts +0 -127
package/src/security/gate.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SecurityGate — 判定函数集合。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **本文件不是一个已生效的安全门。** 它是一个**判定函数的库**,其中只有一部分
|
|
5
|
+
* 被接进生产路径。别把「文件被 import」读成「这些检查在生产里跑」:
|
|
6
|
+
*
|
|
7
|
+
* - `redactCredentialLeak` — **已接线**(`core/behavior-tasks.ts`、
|
|
8
|
+
* `core/credential-masker/output-scrub.ts`)。
|
|
9
|
+
* - 其余三个 `check*` 方法 — **生产零调用点**,唯一消费者是
|
|
10
|
+
* `test/security/penetration/`。因此 CI 的 `penetration-test` job 全绿只说明
|
|
11
|
+
* **这些判定函数被自己测过**,不说明生产有这三道防线。三条各自的去路(为什么不接、
|
|
12
|
+
* 生产另有哪条防线)登记在
|
|
13
|
+
* `test/integrity/unwired-disposition.test.ts` 的 `KEPT_UNWIRED_METHODS` 里,
|
|
14
|
+
* 由机器两向强制(存在 + 生产零引用)。
|
|
15
|
+
*
|
|
16
|
+
* 想让某条真正生效时:先读那条登记的理由,再决定是接线还是改判据 —— 直接 `import`
|
|
17
|
+
* 进来调是最容易的一步,也是最容易多出一道更弱、更难维护、判据还与被取代者不一致的门。
|
|
18
|
+
*/
|
|
1
19
|
export interface GateResult {
|
|
2
20
|
blocked: boolean
|
|
3
21
|
reason?: string
|
package/src/security/path.ts
CHANGED
|
@@ -7,6 +7,24 @@ import { realpathSync, existsSync } from 'node:fs'
|
|
|
7
7
|
*/
|
|
8
8
|
const BLOCKED_PATHS = ['/etc', '/proc', '/sys', '/dev', '/boot', '/root']
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The same list in canonical form.
|
|
12
|
+
*
|
|
13
|
+
* The check below compares against an already-resolved path, so the entries
|
|
14
|
+
* must be resolved too: on macOS `/etc` is a symlink to `/private/etc`, and
|
|
15
|
+
* comparing `/private/etc/x` against the literal `/etc` never matched — the
|
|
16
|
+
* check was dead there. Entries that don't exist yet keep their literal form
|
|
17
|
+
* (there is nothing to resolve, and a later `realpathSync` of the same path
|
|
18
|
+
* cannot produce a different spelling).
|
|
19
|
+
*/
|
|
20
|
+
const BLOCKED_CANONICAL: string[] = BLOCKED_PATHS.map((p) => {
|
|
21
|
+
try {
|
|
22
|
+
return existsSync(p) ? realpathSync(p) : p
|
|
23
|
+
} catch {
|
|
24
|
+
return p
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
10
28
|
/**
|
|
11
29
|
* Windows UNC paths and NT/Win32/DOS device-namespace prefixes.
|
|
12
30
|
*
|
|
@@ -86,7 +104,7 @@ export function resolveSafe(cwd: string, inputPath: string): string {
|
|
|
86
104
|
}
|
|
87
105
|
|
|
88
106
|
// Check 2: must not target sensitive system directories
|
|
89
|
-
for (const blocked of
|
|
107
|
+
for (const blocked of BLOCKED_CANONICAL) {
|
|
90
108
|
if (canonical === blocked || canonical.startsWith(blocked + '/')) {
|
|
91
109
|
throw new Error(
|
|
92
110
|
`Path rejected: "${inputPath}" resolves to a protected system directory (${blocked}).`,
|
|
@@ -113,8 +131,13 @@ function findExistingParent(p: string): string | undefined {
|
|
|
113
131
|
|
|
114
132
|
/**
|
|
115
133
|
* Check if `child` is within `parent` (or equal to it).
|
|
134
|
+
*
|
|
135
|
+
* Compares path segments, not string prefixes: `/a/b-evil` is NOT within
|
|
136
|
+
* `/a/b`. Both sides are expected to be resolved (no `..`, no trailing slash
|
|
137
|
+
* beyond the root). The trailing-slash normalization below is for callers that
|
|
138
|
+
* hand in an unresolved string such as `/proj/src/`.
|
|
116
139
|
*/
|
|
117
|
-
function isWithin(child: string, parent: string): boolean {
|
|
140
|
+
export function isWithin(child: string, parent: string): boolean {
|
|
118
141
|
// Normalize trailing slashes for comparison
|
|
119
142
|
const c = child.endsWith('/') ? child.slice(0, -1) : child
|
|
120
143
|
const p = parent.endsWith('/') ? parent.slice(0, -1) : parent
|
|
@@ -29,7 +29,42 @@ const KNOWN_COMMANDS = [
|
|
|
29
29
|
'help',
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
-
const KNOWN_FLAGS = [
|
|
32
|
+
const KNOWN_FLAGS = [
|
|
33
|
+
'--version',
|
|
34
|
+
'-v',
|
|
35
|
+
'-V',
|
|
36
|
+
'--help',
|
|
37
|
+
'-h',
|
|
38
|
+
'--dump-config',
|
|
39
|
+
'--safe-mode',
|
|
40
|
+
'--resume',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Flags that consume the **next** argument as their value. That value is not a
|
|
45
|
+
* command, so it must not go through the unknown-command check: with
|
|
46
|
+
* `mipham --resume "my session"` the old scan found the first token that didn't
|
|
47
|
+
* start with `-`, concluded the user had typed a command, and reported
|
|
48
|
+
* `Unknown command: mipham my session` — blaming the session name and never
|
|
49
|
+
* mentioning `--resume`, the one argument that was actually wrong.
|
|
50
|
+
*/
|
|
51
|
+
const VALUE_FLAGS = ['--resume']
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The first token that would be read as a command, skipping flags and the values
|
|
55
|
+
* of value-taking flags. `null` when there is none.
|
|
56
|
+
*/
|
|
57
|
+
function firstPositional(args: string[]): string | null {
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const arg = args[i]!
|
|
60
|
+
if (arg.startsWith('-')) {
|
|
61
|
+
if (VALUE_FLAGS.includes(arg)) i++ // its value is not a command
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
return arg
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
33
68
|
|
|
34
69
|
/** Levenshtein edit distance between two strings. */
|
|
35
70
|
function levenshtein(a: string, b: string): number {
|
|
@@ -64,7 +99,7 @@ function closest(target: string, candidates: string[], maxDist = 3): string[] {
|
|
|
64
99
|
* command is present, instead of falling through to the interactive CLI.
|
|
65
100
|
*/
|
|
66
101
|
export function detectUnknownArgument(args: string[]): UnknownArgument | null {
|
|
67
|
-
const firstArg = args
|
|
102
|
+
const firstArg = firstPositional(args)
|
|
68
103
|
if (firstArg && !KNOWN_COMMANDS.includes(firstArg)) {
|
|
69
104
|
return { kind: 'command', arg: firstArg, suggestions: closest(firstArg, KNOWN_COMMANDS) }
|
|
70
105
|
}
|
|
@@ -1,17 +1,40 @@
|
|
|
1
|
-
import { writeFileSync, renameSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Write a file atomically: write to a same-directory
|
|
5
|
+
* Write a file atomically: write to a same-directory temp file, then rename
|
|
5
6
|
* over the target. Same-filesystem rename is atomic, so a crash or kill
|
|
6
7
|
* mid-write can never leave a truncated/corrupt file — readers see either the
|
|
7
8
|
* old or the new content, never a partial write.
|
|
9
|
+
*
|
|
10
|
+
* The temp name carries the pid and a random suffix rather than being a fixed
|
|
11
|
+
* `path + '.tmp'`. Several callers write to *shared* locations (the telemetry
|
|
12
|
+
* queue on every process exit, skill usage, the CRSI ledger), so two sessions or
|
|
13
|
+
* daemon workers on one machine can be inside this function for the same path at
|
|
14
|
+
* the same time. With one shared temp name that interleaving loses a write (the
|
|
15
|
+
* first rename moves the *second* writer's content into place) and then throws
|
|
16
|
+
* ENOENT on the other writer's rename — in a function whose whole point is that
|
|
17
|
+
* the target is never observed half-written. Same directory is still required:
|
|
18
|
+
* rename is only atomic within a filesystem.
|
|
8
19
|
*/
|
|
9
20
|
export function atomicWriteFileSync(
|
|
10
21
|
path: string,
|
|
11
22
|
content: string,
|
|
12
23
|
options: { mode?: number } = {},
|
|
13
24
|
): void {
|
|
14
|
-
const tmp = path
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
const tmp = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
|
|
28
|
+
renameSync(tmp, path)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
// Clean up on the failure path: a fixed temp name used to be overwritten by
|
|
31
|
+
// the next writer, but unique names mean every abandoned write would leave
|
|
32
|
+
// its own orphan forever — nothing else ever sweeps this directory.
|
|
33
|
+
try {
|
|
34
|
+
unlinkSync(tmp)
|
|
35
|
+
} catch {
|
|
36
|
+
// Already renamed away (or never created) — nothing to clean.
|
|
37
|
+
}
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
17
40
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.81.
|
|
12
|
+
export const PACKAGE_VERSION = '0.81.9' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/sanitize.ts
CHANGED
|
@@ -10,14 +10,39 @@
|
|
|
10
10
|
* to ASCII equivalents for permission checks.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Invisible/formatting code points that can hide content.
|
|
15
|
+
*
|
|
16
|
+
* Written as `\u{…}` escapes (hence the `u` flag) so the set is *auditable*: the
|
|
17
|
+
* same list spelled as literal characters is unreviewable in a diff, which is how
|
|
18
|
+
* the tag block (U+E0000–E007F) went missing while every neighbouring family was
|
|
19
|
+
* already covered.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately **not** included — variation selectors (U+FE00–FE0F):
|
|
22
|
+
* - they are load-bearing in emoji (e.g. U+2764 U+FE0F), so stripping them
|
|
23
|
+
* visibly changes text;
|
|
24
|
+
* - `sanitizeParams` feeds `tool.execute` (`tools/validation.ts`), so the strip is
|
|
25
|
+
* applied to what tools *write to disk*, not only to what gets pattern-matched.
|
|
26
|
+
* Adding them here would silently rewrite file contents.
|
|
27
|
+
*
|
|
28
|
+
* The tag block (U+E0000–E007F) *is* included: it is invisible in itself, and the
|
|
29
|
+
* only sequences that use it (subdivision flags, e.g. U+1F3F4 + a tag run) degrade
|
|
30
|
+
* to the bare black flag — which renders the same.
|
|
31
|
+
*/
|
|
32
|
+
const DANGEROUS_UNICODE =
|
|
33
|
+
/[\u{061C}\u{115F}-\u{1160}\u{180E}\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{3164}\u{FEFF}\u{FFA0}\u{E0000}-\u{E007F}]/gu
|
|
14
34
|
|
|
15
35
|
/**
|
|
16
36
|
* Strip dangerous invisible Unicode characters from a string.
|
|
17
37
|
* - Zero-width: U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E/F (LTR/RTL marks)
|
|
18
|
-
* - Bidi controls: U+202A-E, U+2066-9
|
|
38
|
+
* - Bidi controls: U+202A-E, U+2066-9, U+061C (Arabic letter mark)
|
|
19
39
|
* - Word joiner: U+2060
|
|
20
40
|
* - BOM: U+FEFF
|
|
41
|
+
* - Invisible fillers: U+115F/1160 (Hangul choseong/jungseong), U+3164 (Hangul),
|
|
42
|
+
* U+FFA0 (halfwidth Hangul), U+180E (Mongolian vowel separator)
|
|
43
|
+
* - Tag characters: U+E0000-E007F (deprecated, invisible, hide arbitrary text)
|
|
44
|
+
*
|
|
45
|
+
* See `DANGEROUS_UNICODE` for which *adjacent* families are left in place, and why.
|
|
21
46
|
*/
|
|
22
47
|
export function stripDangerousUnicode(input: string): string {
|
|
23
48
|
if (!input) return input
|
package/src/shared/types.ts
CHANGED
|
@@ -138,6 +138,15 @@ export interface StreamChunk {
|
|
|
138
138
|
inputTokens?: number
|
|
139
139
|
/** API-reported output token count (type: 'usage'). */
|
|
140
140
|
outputTokens?: number
|
|
141
|
+
/**
|
|
142
|
+
* The provider stopped because it hit the output token ceiling
|
|
143
|
+
* (OpenAI `finish_reason: 'length'` / Anthropic `stop_reason: 'max_tokens'`).
|
|
144
|
+
* Set **only when true** — absent on every normal stop, so the success path
|
|
145
|
+
* stays byte-identical. Without it a truncated turn is indistinguishable
|
|
146
|
+
* from a turn the model chose to end: the provider emits its terminal stop
|
|
147
|
+
* either way, and tool calls cut off mid-arguments are dropped silently.
|
|
148
|
+
*/
|
|
149
|
+
truncated?: boolean
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
// ── Config Types ──
|
|
@@ -267,6 +276,14 @@ export interface HookDefinition {
|
|
|
267
276
|
|
|
268
277
|
export interface HookContext {
|
|
269
278
|
event: HookEvent
|
|
279
|
+
/**
|
|
280
|
+
* The workspace this invocation is for.
|
|
281
|
+
*
|
|
282
|
+
* Stamped by `HookEngine` from its own cwd; hooks read it out of stdin and run
|
|
283
|
+
* in it. It is the *session's* cwd, which is not `process.cwd()` in the daemon
|
|
284
|
+
* (many sessions, one process).
|
|
285
|
+
*/
|
|
286
|
+
cwd?: string
|
|
270
287
|
toolName?: string
|
|
271
288
|
toolInput?: Record<string, unknown>
|
|
272
289
|
toolResult?: ToolResult
|
package/src/shared/update.ts
CHANGED
|
@@ -26,10 +26,20 @@ const REGISTRIES = [
|
|
|
26
26
|
export interface UpdateCheck {
|
|
27
27
|
/** Current installed version */
|
|
28
28
|
current: string
|
|
29
|
-
/** Latest version on npm */
|
|
29
|
+
/** Latest version on npm — only meaningful when `checked` is true */
|
|
30
30
|
latest: string
|
|
31
31
|
/** Whether an update is available */
|
|
32
32
|
available: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Whether the registry was actually reached.
|
|
35
|
+
*
|
|
36
|
+
* `false` means `latest`/`available` carry **no information**: the check
|
|
37
|
+
* failed and `latest` is just `current`. Without this field the two states
|
|
38
|
+
* are the same value — "we asked, you're current" and "we couldn't ask" both
|
|
39
|
+
* read as `available: false`, which is how `/upgrade` came to print
|
|
40
|
+
* "Already up to date" while offline.
|
|
41
|
+
*/
|
|
42
|
+
checked: boolean
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
/**
|
|
@@ -101,15 +111,19 @@ export function checkForUpdates(): UpdateCheck {
|
|
|
101
111
|
const current = getCurrentVersion()
|
|
102
112
|
let latest = current
|
|
103
113
|
let available = false
|
|
114
|
+
let checked = false
|
|
104
115
|
|
|
105
116
|
try {
|
|
106
117
|
latest = fetchLatestVersion()
|
|
107
118
|
available = compareVersions(latest, current) > 0
|
|
119
|
+
checked = true
|
|
108
120
|
} catch {
|
|
109
|
-
//
|
|
121
|
+
// Registry unreachable — stay quiet rather than alarm, but do **not** claim
|
|
122
|
+
// we are current: `checked: false` is what lets `/upgrade` say "couldn't
|
|
123
|
+
// check" instead of "Already up to date".
|
|
110
124
|
}
|
|
111
125
|
|
|
112
|
-
return { current, latest, available }
|
|
126
|
+
return { current, latest, available, checked }
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
/**
|
|
@@ -222,13 +236,16 @@ export async function checkForUpdatesAsync(): Promise<UpdateCheck> {
|
|
|
222
236
|
const current: string = PACKAGE_VERSION
|
|
223
237
|
let latest: string = current
|
|
224
238
|
let available = false
|
|
239
|
+
let checked = false
|
|
225
240
|
try {
|
|
226
241
|
latest = await fetchLatestVersionAsync()
|
|
227
242
|
available = compareVersions(latest, current) > 0
|
|
243
|
+
checked = true
|
|
228
244
|
} catch {
|
|
229
|
-
// offline →
|
|
245
|
+
// offline → don't alarm the user, but don't report `available: false` as if
|
|
246
|
+
// it were an answer either (`checked` is what keeps the two apart)
|
|
230
247
|
}
|
|
231
|
-
return { current, latest, available }
|
|
248
|
+
return { current, latest, available, checked }
|
|
232
249
|
}
|
|
233
250
|
|
|
234
251
|
/**
|
package/src/tools/agent/agent.ts
CHANGED
|
@@ -88,6 +88,9 @@ export const agentTool: ToolDefinition = {
|
|
|
88
88
|
type: agentType,
|
|
89
89
|
agentDef,
|
|
90
90
|
runInBackground,
|
|
91
|
+
// Hand the caller's services down: the sub-agent keeps running the same
|
|
92
|
+
// skills/agents/artifacts, and only the fields it owns are overridden.
|
|
93
|
+
toolContext: ctx,
|
|
91
94
|
})
|
|
92
95
|
|
|
93
96
|
// If background execution, also register in the task system for Task tool integration
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
-
import {
|
|
4
|
+
import { ARTIFACT_MAX_SIZE } from '../../shared/constants'
|
|
5
|
+
import { artifactsRoot } from '../../artifacts/paths'
|
|
5
6
|
import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
|
|
6
7
|
|
|
7
8
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
|
|
@@ -57,8 +58,10 @@ export const artifactTool: ToolDefinition = {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
// Determine output paths
|
|
61
|
-
|
|
61
|
+
// Determine output paths — the server's root, the manifest's home and this
|
|
62
|
+
// directory are one and the same; computing it here is what made the URL a
|
|
63
|
+
// guaranteed 404 before.
|
|
64
|
+
const baseDir = artifactsRoot(ctx.cwd)
|
|
62
65
|
const sessionDir = join(baseDir, ctx.sessionId)
|
|
63
66
|
mkdirSync(sessionDir, { recursive: true })
|
|
64
67
|
|
|
@@ -100,7 +103,7 @@ export const artifactTool: ToolDefinition = {
|
|
|
100
103
|
const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
|
|
101
104
|
const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
|
|
102
105
|
|
|
103
|
-
addToManifest(
|
|
106
|
+
const { quarantined } = addToManifest(
|
|
104
107
|
baseDir,
|
|
105
108
|
{
|
|
106
109
|
name,
|
|
@@ -124,6 +127,12 @@ export const artifactTool: ToolDefinition = {
|
|
|
124
127
|
const galleryUrl = port ? `http://localhost:${port}` : undefined
|
|
125
128
|
const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
|
|
126
129
|
const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
|
|
130
|
+
// The index was unreadable and got moved aside, so this publish started from
|
|
131
|
+
// nothing: say it here, or the user reads "saved" and never learns that the
|
|
132
|
+
// rest of the index is now a file next to it.
|
|
133
|
+
const warnLine = quarantined
|
|
134
|
+
? ` ⚠️ Index was unreadable; previous index kept at ${quarantined}`
|
|
135
|
+
: ''
|
|
127
136
|
|
|
128
137
|
return {
|
|
129
138
|
success: true,
|
|
@@ -132,6 +141,7 @@ export const artifactTool: ToolDefinition = {
|
|
|
132
141
|
` URL: ${url}`,
|
|
133
142
|
` Size: ${size.toLocaleString()} bytes`,
|
|
134
143
|
versionLine,
|
|
144
|
+
warnLine,
|
|
135
145
|
galleryLine,
|
|
136
146
|
'',
|
|
137
147
|
`Open in browser: /artifact open ${name}`,
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
1
3
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
2
4
|
import { sanitizeCommand } from '../../shared/sanitize.ts'
|
|
3
5
|
import { DANGEROUS_GIT_PATTERNS } from './git.ts'
|
|
4
|
-
import { isUncOrDevicePath } from '../../security/path.ts'
|
|
6
|
+
import { isUncOrDevicePath, isWithin } from '../../security/path.ts'
|
|
5
7
|
import { findWorktreeMarker } from '../../core/paths.ts'
|
|
6
8
|
import type { Service } from '../../vajra'
|
|
7
9
|
import { toolKey } from '../seam'
|
|
@@ -308,6 +310,36 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
|
|
|
308
310
|
return unique.slice(0, 10)
|
|
309
311
|
}
|
|
310
312
|
|
|
313
|
+
/**
|
|
314
|
+
* 找出命令里第一个 `cd` 到工作区之外的**目标原样字符串**(供错误文案用);
|
|
315
|
+
* 无逃逸返回 null。判定边界是 `worktreeRoot`(项目根),不是 `cwd` ——
|
|
316
|
+
* 既有行为即如此:`cd <项目内其它目录>` 放行(见 test/tools/exec.test.ts
|
|
317
|
+
* 「allows cd inside the project from a .mipham worktree」)。
|
|
318
|
+
*
|
|
319
|
+
* 此前三个缺陷,其中两个是活的绕过:
|
|
320
|
+
* - 相对路径用字符串拼接而非 `resolve`:`cd ../../../..` 拼出来的串仍以
|
|
321
|
+
* cwd 开头,于是被当成「在区内」放行 —— **活绕过**;
|
|
322
|
+
* - `command.match(...)` 非全局,只看第一个 `cd`,`cd sub && cd /etc` 的
|
|
323
|
+
* 后半段完全不检查 —— **活绕过**;
|
|
324
|
+
* - 归属判定用 `resolved.startsWith(cwd)` 字符串前缀比较,`/proj/w1-evil`
|
|
325
|
+
* 会被判成「在 /proj/w1 里」;它只被 root 那个析取项兜住才没显形,故一并
|
|
326
|
+
* 改成按路径分段比较的 `isWithin`。
|
|
327
|
+
*/
|
|
328
|
+
export function resolveWorktreeEscape(
|
|
329
|
+
cwd: string,
|
|
330
|
+
worktreeRoot: string,
|
|
331
|
+
command: string,
|
|
332
|
+
): string | null {
|
|
333
|
+
const cdRe = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/g
|
|
334
|
+
for (const m of command.matchAll(cdRe)) {
|
|
335
|
+
const target = m[1] ?? m[2] ?? m[3]
|
|
336
|
+
if (!target) continue
|
|
337
|
+
const resolved = resolve(cwd, target)
|
|
338
|
+
if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) return target
|
|
339
|
+
}
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
|
|
311
343
|
export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
312
344
|
return {
|
|
313
345
|
name: 'Bash',
|
|
@@ -332,30 +364,36 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
332
364
|
},
|
|
333
365
|
async execute(params, ctx) {
|
|
334
366
|
const command = params.command as string
|
|
335
|
-
const
|
|
367
|
+
const requestedTimeout = params.timeout as number | undefined
|
|
368
|
+
// A negative timeout is not "no timeout". `Math.min(-1 || 120_000, 600_000)` is `-1`,
|
|
369
|
+
// and `setTimeout(fn, -1)` is clamped to **1 ms** — so the command is group-killed on
|
|
370
|
+
// the spot and reported as a bare `Exit code 137` (measured with real bun). Node does
|
|
371
|
+
// warn, but only on stderr, where the model never sees it. Refuse rather than silently
|
|
372
|
+
// reinterpret; `0`, `NaN` and `undefined` already fall back to the default via `||`.
|
|
373
|
+
if (typeof requestedTimeout === 'number' && requestedTimeout < 0) {
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
content: '',
|
|
377
|
+
error:
|
|
378
|
+
`timeout must not be negative (got ${requestedTimeout}ms). ` +
|
|
379
|
+
`Omit it for the 120000ms default.`,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const timeout = Math.min(requestedTimeout || 120_000, 600_000)
|
|
336
383
|
|
|
337
384
|
// P0-4: Worktree isolation — block cd escape attempts
|
|
338
385
|
// 标记取自 core/paths.ts:新目录与历史 .claude/worktrees/ 都认,
|
|
339
386
|
// 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
|
|
340
387
|
const worktreeMarker = findWorktreeMarker(ctx.cwd)
|
|
341
388
|
if (worktreeMarker) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
: `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
|
|
351
|
-
if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
|
|
352
|
-
return {
|
|
353
|
-
success: false,
|
|
354
|
-
content: '',
|
|
355
|
-
error:
|
|
356
|
-
`Worktree isolation: cannot cd outside worktree directory. ` +
|
|
357
|
-
`Attempted: ${target}. Use tools within the worktree only.`,
|
|
358
|
-
}
|
|
389
|
+
const escapeTarget = resolveWorktreeEscape(ctx.cwd, worktreeMarker.root, command)
|
|
390
|
+
if (escapeTarget !== null) {
|
|
391
|
+
return {
|
|
392
|
+
success: false,
|
|
393
|
+
content: '',
|
|
394
|
+
error:
|
|
395
|
+
`Worktree isolation: cannot cd outside worktree directory. ` +
|
|
396
|
+
`Attempted: ${escapeTarget}. Use tools within the worktree only.`,
|
|
359
397
|
}
|
|
360
398
|
}
|
|
361
399
|
}
|
|
@@ -379,15 +417,42 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
379
417
|
stdout: 'pipe',
|
|
380
418
|
stderr: 'pipe',
|
|
381
419
|
env: spawnEnv,
|
|
420
|
+
// Own process group. Required for the group kill below to reach
|
|
421
|
+
// grandchildren — and for it to target *our* group at all: without
|
|
422
|
+
// this the child inherits the parent's pgid, so `kill(-pid)` aims at
|
|
423
|
+
// the wrong group and fails (or, worse, hits the parent's).
|
|
424
|
+
detached: true,
|
|
382
425
|
})
|
|
383
426
|
|
|
384
|
-
|
|
385
|
-
|
|
427
|
+
// Start reading at once — a child that fills the pipe buffer blocks on
|
|
428
|
+
// write and would then never exit — but do **not** await here: if a
|
|
429
|
+
// descendant inherits the pipe and outlives the shell, EOF never comes,
|
|
430
|
+
// and awaiting this before `exited` is what hung the call for good.
|
|
431
|
+
const stdoutRead = new Response(proc.stdout).text()
|
|
432
|
+
const stderrRead = new Response(proc.stderr).text()
|
|
433
|
+
|
|
434
|
+
// Remember whether we were the ones who killed it: the exit code is 137 and
|
|
435
|
+
// stderr is empty either way, so the model cannot tell a timeout from the
|
|
436
|
+
// command's own failure (measured with real bun — both are `Exit code 137: `).
|
|
437
|
+
let timedOut = false
|
|
438
|
+
const timer = setTimeout(() => {
|
|
439
|
+
timedOut = true
|
|
440
|
+
killProcessGroup(proc.pid)
|
|
441
|
+
}, timeout)
|
|
386
442
|
const exitCode = await proc.exited
|
|
387
443
|
clearTimeout(timer)
|
|
388
444
|
|
|
445
|
+
// The shell is gone, so only a pipe-holding descendant can still be
|
|
446
|
+
// holding these up. Released by the group kill, at most once.
|
|
447
|
+
let released = false
|
|
448
|
+
const release = () => {
|
|
449
|
+
if (released) return
|
|
450
|
+
released = true
|
|
451
|
+
killProcessGroup(proc.pid)
|
|
452
|
+
}
|
|
453
|
+
const rawOutput = await settlePipe(stdoutRead, release)
|
|
389
454
|
// Read stderr for violation detection and error reporting
|
|
390
|
-
const rawStderr = await
|
|
455
|
+
const rawStderr = await settlePipe(stderrRead, release)
|
|
391
456
|
|
|
392
457
|
// ── Credential masking: scrub output ──
|
|
393
458
|
let output = rawOutput
|
|
@@ -428,7 +493,9 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
428
493
|
return {
|
|
429
494
|
success: false,
|
|
430
495
|
content: errorContent,
|
|
431
|
-
error:
|
|
496
|
+
error: timedOut
|
|
497
|
+
? `Command timed out after ${timeout}ms (killed): ${stderr.slice(0, 1_000)}`
|
|
498
|
+
: `Exit code ${exitCode}: ${stderr.slice(0, 1_000)}`,
|
|
432
499
|
}
|
|
433
500
|
}
|
|
434
501
|
|
|
@@ -438,16 +505,71 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
438
505
|
}
|
|
439
506
|
return { success: true, content: successContent }
|
|
440
507
|
} catch (err) {
|
|
508
|
+
// A missing `cwd` and a missing `bash` both surface as the *same*
|
|
509
|
+
// `ENOENT: no such file or directory, posix_spawn 'bash'` (measured), which reads
|
|
510
|
+
// as "bash is not installed" and points the model at the wrong root cause. The cwd
|
|
511
|
+
// is the one we can actually check — so check it, and only claim it when it is
|
|
512
|
+
// genuinely absent, or a truly missing bash would get relabelled as a bad cwd.
|
|
513
|
+
const code = (err as NodeJS.ErrnoException | undefined)?.code
|
|
441
514
|
return {
|
|
442
515
|
success: false,
|
|
443
516
|
content: '',
|
|
444
|
-
error:
|
|
517
|
+
error:
|
|
518
|
+
code === 'ENOENT' && !existsSync(ctx.cwd)
|
|
519
|
+
? `Working directory does not exist: ${ctx.cwd}`
|
|
520
|
+
: `Command failed: ${String(err)}`,
|
|
445
521
|
}
|
|
446
522
|
}
|
|
447
523
|
},
|
|
448
524
|
}
|
|
449
525
|
}
|
|
450
526
|
|
|
527
|
+
/** Grace given to a descendant still holding the output pipe after the shell itself has exited. */
|
|
528
|
+
const PIPE_GRACE_MS = 1_000
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Kill a whole process group. `proc.kill()` reaches only the direct child, so a
|
|
532
|
+
* `bash -c` that spawned its own children leaves them orphaned and unnotified.
|
|
533
|
+
* The negative pid addresses the group led by that pid, which exists only when
|
|
534
|
+
* the child was spawned `detached` — verified on this host: without it the
|
|
535
|
+
* child's pgid is the *parent's* group, and this call fails with ESRCH rather
|
|
536
|
+
* than reaching the grandchildren.
|
|
537
|
+
*/
|
|
538
|
+
export function killProcessGroup(pid: number | undefined): void {
|
|
539
|
+
if (pid === undefined || pid <= 0) return
|
|
540
|
+
try {
|
|
541
|
+
process.kill(-pid, 'SIGKILL')
|
|
542
|
+
} catch (err: unknown) {
|
|
543
|
+
// ESRCH: the group is already gone, which is the normal case when the
|
|
544
|
+
// command finished on its own. Anything else means a group kill isn't
|
|
545
|
+
// available here, so fall back to the direct child.
|
|
546
|
+
if ((err as NodeJS.ErrnoException).code === 'ESRCH') return
|
|
547
|
+
try {
|
|
548
|
+
process.kill(pid, 'SIGKILL')
|
|
549
|
+
} catch {
|
|
550
|
+
// Already gone.
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Await an already-started pipe read, but not forever. A descendant that
|
|
557
|
+
* inherited the pipe keeps EOF from arriving, and that read is what used to
|
|
558
|
+
* hang the call after the command itself had finished. Once the grace expires,
|
|
559
|
+
* take the group down — which closes the pipe — and finish the read.
|
|
560
|
+
*/
|
|
561
|
+
async function settlePipe<T>(read: Promise<T>, release: () => void): Promise<T> {
|
|
562
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
563
|
+
const stalled = new Promise<null>((resolve) => {
|
|
564
|
+
timer = setTimeout(() => resolve(null), PIPE_GRACE_MS)
|
|
565
|
+
})
|
|
566
|
+
const winner = await Promise.race([read, stalled])
|
|
567
|
+
clearTimeout(timer)
|
|
568
|
+
if (winner !== null) return winner
|
|
569
|
+
release()
|
|
570
|
+
return read
|
|
571
|
+
}
|
|
572
|
+
|
|
451
573
|
export const bashToolService: Service = {
|
|
452
574
|
inject: ['credentials'],
|
|
453
575
|
apply(ctx) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
-
import { worktreeRoot } from '../../core/paths.ts'
|
|
2
|
+
import { listsWorktree, worktreeRoot } from '../../core/paths.ts'
|
|
3
3
|
|
|
4
4
|
export const enterWorktreeTool: ToolDefinition = {
|
|
5
5
|
name: 'EnterWorktree',
|
|
@@ -95,7 +95,7 @@ export const enterWorktreeTool: ToolDefinition = {
|
|
|
95
95
|
stderr: 'pipe',
|
|
96
96
|
})
|
|
97
97
|
const existingWorktrees = await new Response(checkProc.stdout).text()
|
|
98
|
-
if (existingWorktrees
|
|
98
|
+
if (listsWorktree(existingWorktrees, worktreePath)) {
|
|
99
99
|
return {
|
|
100
100
|
success: true,
|
|
101
101
|
content:
|
|
@@ -126,8 +126,14 @@ export const enterWorktreeTool: ToolDefinition = {
|
|
|
126
126
|
|
|
127
127
|
// Create worktree with new branch
|
|
128
128
|
const branchName = `worktree/${name}`
|
|
129
|
+
// `--` ends git's option parsing. `baseRef` comes from the model and is
|
|
130
|
+
// otherwise unvalidated, so without it a ref spelled `--force` is read as
|
|
131
|
+
// an *option*: measured on this machine, `git worktree add -b b <path>
|
|
132
|
+
// --force` succeeds, while a bogus ref in that same slot is `fatal:
|
|
133
|
+
// invalid reference`. After `--` git reads it as a ref and rejects
|
|
134
|
+
// nonsense, which is what a bad base ref should do.
|
|
129
135
|
const proc = Bun.spawn(
|
|
130
|
-
['git', 'worktree', 'add', '-b', branchName, worktreePath, resolvedBaseRef],
|
|
136
|
+
['git', 'worktree', 'add', '-b', branchName, worktreePath, '--', resolvedBaseRef],
|
|
131
137
|
{ cwd, stdout: 'pipe', stderr: 'pipe' },
|
|
132
138
|
)
|
|
133
139
|
const _stdout = await new Response(proc.stdout).text()
|