File Editing and Shell Execution: Exact-Match Replacement, Conflict Detection, and a Timeout-Killable Subprocess
Upgrade read access to write access: implement an edit tool based on exact-match replacement and a shell tool that runs commands, handle four real failure modes — stale content not matching, files changed externally, a command hanging, and output too large — then let the agent fix a failing test green for the first time.
Today's Goals
- Explain the failure modes of exact-match replacement versus patch-style editing, and pick the one that suits a model
- Implement an edit tool with conflict detection, so concurrent or external changes are never silently overwritten
- Implement a command-execution tool that can time out, be killed, and cap its output
Today mca changes your code for the first time. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Now give write access: the risk changes by an order of magnitude
Yesterday that new hire got read access. Today you grant commit rights — and before pressing that button your state of mind is nothing like yesterday's.
The difference is not their ability but reversibility. Read the wrong file yesterday and five minutes were wasted; edit the wrong file today and half a day of someone else's work may be overwritten. And write errors have a nasty property: they are often silent. A wrong read announces itself ("this is not what I was looking for"); a wrong edit leaves the program running and the tests green, and the problem surfaces two weeks later.
Adding write tools to an Agent is riskier still than to a new hire, because it has three traits a new hire does not:
- It has no instinct for "I am not sure, let me ask." Give it an editing tool and it uses it; it has no awareness that it might be misremembering the original text.
- It can change many places at once. A new hire edits one file wrongly; an Agent edits five in one turn.
- Its input is guessed. The code it writes into
old_stringcame from aread_fileseveral turns ago, and that content may be stale.
So today's focus is not "how to put characters into a file" — that is one line of writeFile. Today's focus is three failure modes and how to make each fail loudly enough: the original does not match, the original is not unique, and a command hangs without returning. Handle these badly and you get a tool that looks usable and occasionally breaks things quietly — the worst class of bug in a Coding Agent, because it raises no error.
So what shape should the write tool take? The three mainstream approaches each fail in their own way; lay them out side by side.
Three editing approaches: where the model crashes in each
There are only three industry approaches to letting a model change a file.
| Approach | What the model must output | Main failure mode | Token cost |
|---|---|---|---|
| Whole-file rewrite | the complete file after the change | "improving" places you never asked about; truncation on long files | highest |
| Patch (diff) | hunks with line numbers and context lines | one wrong line number voids the hunk; failure reasons are hard to translate | lowest |
| Exact replacement | a short original snippet plus its replacement | misremembered original; original not unique | low |
Whole-file rewrite is the easiest to implement and the one you should least use. It has a subtle harm: while re-emitting the lines it was not supposed to touch, the model quietly changes a few it dislikes — a variable name, formatting, a comment. Mixed into one big diff, a human cannot review that. And rewriting long files is slow, expensive and may be truncated into half a file by the output limit.
Patches cost the fewest tokens and most resemble how a human engineer works, but they are unfriendly to models: line numbers and context lines must line up exactly, and one wrong line voids the hunk. Worse, failure offers no actionable hint — "patch failed: context mismatch at line 42" leaves the model to retry the same miscalculated reasoning.
Exact replacement is the best trade-off: the model outputs only the snippet to change and its replacement. Failure reasons are specific enough to translate into its next action: "that content cannot be found" means "you misremembered, read it again"; "that content appears 3 times" means "write more context." That is the real reason for choosing it — not that it costs fewer tokens, but that it fails more teachably.
Conflict detection: is the content it read still there?
Now the implementation. Its core is one sentence: count the occurrences before deciding whether to write.
const before = await fs.readFile(target, 'utf8')
// Count occurrences instead of replacing directly: both 0 and many must fail,
// each with an actionable reason
const occurrences = before.split(oldString).length - 1
if (occurrences === 0) {
return {
ok: false,
content:
`that old_string cannot be found in ${relative}; nothing was changed. ` +
'Commonly the indentation or newlines differ, or the file already changed - read_file first and retry.',
}
}
if (occurrences > 1) {
return {
ok: false,
content:
`that old_string appears ${occurrences} times in ${relative}, so the target is ambiguous; nothing was changed. ` +
'Write more context so it becomes unique in the file.',
}
}
const after = before.replace(oldString, newString)
await fs.writeFile(target, after, 'utf8')before = target.read_text(encoding="utf-8")
# Count occurrences instead of replacing directly: both 0 and many must fail,
# each with an actionable reason
occurrences = before.count(old_string)
if occurrences == 0:
return ToolResult(
ok=False,
content=(
f"that old_string cannot be found in {relative}; nothing was changed. "
"Commonly the indentation or newlines differ, or the file already changed - "
"read_file first and retry."
),
)
if occurrences > 1:
return ToolResult(
ok=False,
content=(
f"that old_string appears {occurrences} times in {relative}, so the target is "
"ambiguous; nothing was changed. Write more context so it becomes unique."
),
)
target.write_text(before.replace(old_string, new_string, 1), encoding="utf-8")The most important part of that code is what it does not do: no fuzzy matching, no whitespace-insensitive comparison, no guessing which occurrence was meant.
The reason is that this rule doubles as conflict detection. Consider where the model's old_string came from: a read_file several turns ago. In between, the file may have been edited by you in your editor, changed by the previous replacement, or swapped by a git branch switch. "The original cannot be found" is then not the model's mistake but a genuine conflict signal — the world it read is no longer the world that exists. The only correct action is to abandon the write and say why. Any fuzzy matching is a gamble whose losing outcome is a silent overwrite.
That is why the starter's "just replace the first occurrence" implementation is the day's most dangerous bug: it is perfectly correct when the original is unique, silently changes the wrong place when it is not, and reports "changed" to the model. The tests may even stay green, because the place it changed happened not to matter.
An aside on the other approach to conflict detection: record the file's modification time or a content hash at read, and compare before writing. It is stricter (it even catches "changed and changed back") but unfriendly to models — the failure reason is "the file was modified externally," which the model can do nothing about except re-read. Content matching's advantage is that its failure reason is inherently actionable, so this course uses it. In real engineering the two can be stacked: content matching for the model, hash comparison for the human.
Creation and deletion are two special cases
With exact replacement in place, it is tempting to fold in creation and deletion: creation is an empty old_string, deletion an empty new_string. Do not.
Creation's problem is that it has no original to match — the protection disappears entirely. And this tool's whole safety rests on "the original must match," so allowing an empty original lets one slip overwrite an existing file with new content, and the diff will not show what was destroyed. So creation is its own tool, and must fail when the target already exists: to change an existing file, use the editing tool, where original matching protects you.
Deletion is more extreme: it is irreversible with no partial-success middle state. This course gives it no dedicated tool and routes it through the command tool, so day five's approval gate can stop it and a human can look before deciding. Passing an empty new_string to delete a snippet is fine — it still has original matching as protection, and it removes a piece of a file rather than the whole file.
A takeaway criterion: whatever premise a tool's safety rests on, do not let it accept input where that premise fails.
The shell tool's four gates
The command tool is the riskiest of the course: it can run anything, including things you did not think of. Four gates, none skippable.
One: a timeout. Thirty seconds by default, relaxable by parameter but with a ceiling. Without one, the command tool hangs the whole Agent on an interactive program waiting for input, and the user cannot see what it is waiting for. A small trick alongside: set the child's standard input to "ignore," and interactive commands hit end-of-file and exit on their own instead of waiting out the timeout.
Two: an output cap. Count standard output and standard error separately, four thousand characters each. Separation matters: one flood of warnings would otherwise squeeze out the real error. And cap while receiving, not after collecting — collecting first still holds those hundreds of thousands of characters in memory. In this lab, a command printing twenty thousand lines produces 208890 characters of standard output uncapped, and only 4000 with the cap, for a total fed-back result of 4085 characters.
const MAX_STREAM_CHARS = 4000
const capture = (which: 'out' | 'err') => (chunk: Buffer) => {
const text = chunk.toString('utf8')
// Cap while receiving: collecting first still holds those hundreds of thousands of chars
if (which === 'out') {
if (stdout.length < MAX_STREAM_CHARS) stdout += text.slice(0, MAX_STREAM_CHARS - stdout.length)
} else if (stderr.length < MAX_STREAM_CHARS) {
stderr += text.slice(0, MAX_STREAM_CHARS - stderr.length)
}
}
child.stdout?.on('data', capture('out'))
child.stderr?.on('data', capture('err'))MAX_STREAM_CHARS = 4000
async def capture(stream: asyncio.StreamReader, sink: list[str]) -> None:
"""Cap while receiving: each stream has its own cap so warnings cannot bury the error"""
kept = 0
while chunk := await stream.read(4096):
if kept >= MAX_STREAM_CHARS:
continue # keep reading anyway, or a full pipe blocks the child on write
text = chunk.decode("utf-8", "replace")[: MAX_STREAM_CHARS - kept]
kept += len(text)
sink.append(text)That Python comment deserves a pause: keep reading even past the cap. Pipes have buffers, and if you stop reading, the child blocks once its buffer fills — the symptom is "the command inexplicably hangs," and only when output is large.
Three: the working directory. Pinned inside the sandbox repository. The command tool needs this more than file tools do, because escaping the boundary from a shell is far too easy.
Four: refuse obviously dangerous commands. Recursive deletion, shutdown, writing to a block device, downloading a script and executing it, pushing code to a remote — none of these can be rolled back once run. But be clear about this gate's nature: it prevents slips, not attacks. There are a hundred ways around a regex blacklist, and real isolation needs a sandbox (a container, a temporary directory, no network). Tomorrow's three preconditions for automatic mode return here.
The child process that will not die: process groups, signals and orphans
Implementing the timeout is harder than it looks. Write setTimeout and then call kill, and you find some commands are killed and do not stop.
The reason is the process tree. You start a shell, the shell starts the program that does the work, and that program may start more children. Killing only the shell leaves its grandchildren orphaned and running; worse, they still hold the output pipes, so the "child closed" event never arrives and this tool call's promise never resolves — the whole Agent hangs there.
The correct approach has two parts: make the child its own process group at spawn, and kill the whole group.
// detached: true makes the child its own process group - the key to actually killing it
const child = spawn(command, {
cwd: ctx.cwd,
shell: true,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'], // stdin ignored: interactive commands hit EOF instead of hanging
})
const killTree = (): void => {
if (child.pid === undefined) return
try {
process.kill(-child.pid, 'SIGTERM') // the minus sign kills the whole process group
} catch {
/* already gone */
}
// Grace period before force: SIGTERM lets it clean up temp files, SIGKILL does not
setTimeout(() => {
try {
if (child.pid !== undefined) process.kill(-child.pid, 'SIGKILL')
} catch {
/* already gone */
}
}, 2000).unref()
}# start_new_session=True is setsid: the child becomes its own process group,
# which is the key to actually killing it
proc = await asyncio.create_subprocess_shell(
command,
cwd=cwd,
stdin=asyncio.subprocess.DEVNULL, # interactive commands hit EOF instead of hanging
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
start_new_session=True,
)
def kill_tree() -> None:
try:
os.killpg(proc.pid, signal.SIGTERM) # killpg kills the whole process group
except ProcessLookupError:
return
# Grace period before force: SIGTERM lets it clean up temp files, SIGKILL does not
loop = asyncio.get_running_loop()
loop.call_later(2.0, lambda: _force_kill(proc.pid))The lab makes this certain with INJECT=timeout: the injected command never exits, and the shell is deliberately kept as its parent (the command ends with & wait), so "kill only the shell" and "kill the process group" differ visibly. The solution kills it within two seconds and moves on; the starter's shell-only kill hangs forever, and the seventh self-test item stalls to timeout — that is what "killed but not stopped" actually feels like.
As for the user's cancel key, today wires only one link: a cancellation signal also triggers the process-group kill. The full chain — key, signal, request, child process, where one broken link means nothing dies — is day six's subject.
The first closed loop: one sentence, from red to green
Attach today's two tools to yesterday's three read-only ones and the Agent completes a real coding task for the first time. The sandbox repository's divide lacks a divide-by-zero guard, and one test case is red. You say only "please fix the failing test":
Let me run the tests first and see which case is red.
> run_command({"command":"node --test"})
x run_command fed back 1373 chars - exit code 1 (68ms): node --test
The divide-by-zero case failed. Let me read divide's implementation.
> read_file({"path":"src/calc.js"})
v read_file fed back 248 chars - src/calc.js (lines 1-13 of 13)
divide returns a over b directly, so a zero divisor silently returns Infinity. I will add a guard.
> edit_file({"path":"src/calc.js","old_string":"export function divide(a, b) {\n return a /...)
v edit_file fed back 36 chars - changed src/calc.js: 1 replacement from line 10, line delta +1
Done; let me run the tests again to confirm.
> run_command({"command":"node --test"})
v run_command fed back 520 chars - exit code 0 (89ms): node --test
All four cases are green.Three details in that output are worth noting.
One: not a line of the loop layer changed. Yesterday's tool protocol assumed nothing about being read-only, so adding two mutating tools today only registers two more entries. That is the dividend of day three's boundary — adding a tool should touch no other layer.
Two: the model decided on its own to re-run the tests. We wrote no "verify after editing" logic; it inferred that from the tool descriptions and the failure messages. Which explains why failure messages must state the next step: they are the model's behavioural specification.
Three: four tool calls, two of them the same command. One proves red, one proves green. That "run before and after" pattern is the course's baseline task: day six demonstrates self-correction with it, day seven session recovery, day fourteen rollback, and day twenty makes it the benchmark set's first item.
Source Reading
Hands-On Lab
Today is the first time the course really edits files and really spawns child processes. The starter leaves four exercises, two of which (occurrence checking, killing the process group) correspond to this chapter's two most dangerous traps. Unmodified it passes three of seven.
- Implement exact replacement: count occurrences first, fail on both 0 and many with an actionable reason, and confirm not one byte of the file changed on failure.
- Implement the command tool's four gates: timeout, separate capped streams captured while receiving, a fixed working directory, and outright refusal of dangerous commands.
- Hang a command with
INJECT=timeoutand confirm it is killed within two seconds and the loop continues instead of hanging. - Walk the offline script through run tests, read code, edit code, run tests again, and confirm the second exit code is 0.
- Run the self-test:
MOCK=1 SELFTEST=1 pnpm startshould print 7/7 passed, with the tool call order and fed-back character counts both reproducible.
Acceptance is five ticks: the self-test prints 7/7 passed; one sentence produces four tool cards in order with a second test exit code of 0; both a non-matching and a non-unique original are refused with the file untouched; a command printing twenty thousand lines leaves only 4000 characters of standard output; and INJECT=timeout kills the command with the process really gone.
Interview Questions
Today's three questions are all answerable only by someone who has written write tools:
- To let a model change files, do you choose whole-file rewrite, exact replacement or patches? Why?
- How does an editing tool do conflict detection? What should it tell the model when detection fails?
- What constraints would you place on running arbitrary shell commands inside an Agent? After a timeout, how do you actually kill it?
Full bilingual prompts, analyses and key points are in this course's day-four question bank. The second half of question three exposes people most — most can say "add a timeout" and cannot say why it was killed and did not stop.
Checklist and Tomorrow
- I can name the failure modes of the three editing approaches and the real reason for choosing exact replacement
- I know why "unique and exactly matching original" doubles as conflict detection
- I can explain why creation needs its own tool and why it must fail when the target exists
- I can name the command tool's four gates and know the fourth prevents slips, not attacks
- I can explain where an unkillable child process comes from, and what the process group and grace period each solve
- The self-test prints 7/7 passed, and I watched the tests go from red to green
Tomorrow is D5, "Permissions and Approval: Three-State Rules, Matching by Tool and Path, and the Boundary of Automatic Mode." Today gave it write access, but that dangerous-command blacklist only prevents slips; the real question is which operations it may perform alone and which need asking first. Tomorrow turns that judgment into a set of allow, ask and deny rules matched by tool name and path pattern, and makes approval a pause inside the loop rather than an exception — which is where day three's readOnly field finally earns its keep. The order is deliberate: an approval gate is empty talk until tools can cause real loss.
Interview questions
To let a model edit files, do you pick whole-file rewrite, exact string replacement, or patches? Why?让模型改文件,你会选整文件重写、精确替换还是补丁?为什么?
Common in ChinaCommon overseasBasic#file-editing#tool-designHow to reason about it · think before answering
- This checks whether you have actually shipped model-driven edits. Answering use diffs, they are cheaper compares only cost, not failure modes — and failure recovery is where these three really differ.
- How to break it down: attach a failure story to each. Whole-file rewrite forces the model to reproduce untouched code verbatim, so it quietly improves things you never asked about, and those edits hide inside a large diff; long files can also be cut off by output limits. Patches are the cheapest but require exact line numbers and context, so one miscounted line voids the hunk, and the failure message leaves the model repeating the same miscalculation. Exact replacement sends only the target snippet and its replacement, and fails with not found or found three times.
- Conclusion: pick exact replacement, not because it is cheaper but because it fails in a teachable way. Not found translates to you misremembered, read the file again; found three times translates to include more context. A tool's failure message is the model's behavior spec, and only messages that map to a next action are worth anything.
- Add the boundary: exact replacement does not cover large mechanical changes such as project-wide renames. The right answer there is not a different edit format but running a command — a codemod or a regex batch replace — and then running the tests. The test is whether the change is a mechanizable transformation.
- Likely follow-up: why not offer both and let the model choose? Every extra tool is another place it can choose wrong, and two tools with different failure semantics produce conflicting feedback. Prefer one tool that does one thing correctly.
分析过程 · 先想清楚再作答
- 这题在考「有没有真的让模型改过代码」。答「用 diff,更省 token」的人只比较了成本,没比较失败模式——而这三种做法的真正差别在失败之后能不能救回来。
- 怎么拆:给每种做法配一个失败故事。整文件重写:模型要把没改的部分一字不差地重新输出,于是它会顺手「优化」你没让它动的地方,这种改动混在大 diff 里人审不出来,长文件还可能被输出上限截成半个。补丁:最省 token,但行号与上下文行必须完全对上,算错一行整块作废,而且失败原因是「第 42 行上下文不匹配」,模型只能重试一遍它刚才那套算错的逻辑。精确替换:只发要改的那一小段与替换成什么,失败原因是「找不到」或「出现了 3 次」。
- 结论:选精确替换,理由不是省 token,而是**它失败得更可教**。「找不到」直接翻译成「你记错了,先重新读一遍」,「出现了 3 次」直接翻译成「把上下文写长一点」。工具的失败信息就是模型的行为规范,能翻译成下一步动作的失败信息才有价值。
- 补充一条边界:精确替换也有它不能覆盖的场景——大规模重命名、跨文件的机械改动。这类活正确的做法不是换编辑方式,而是让它去跑一条命令(比如代码修改器或者带正则的批量替换),然后跑测试验证。**判据是「这个改动是不是一个可以被工具化的机械变换」。**
- 可预期的追问:那为什么不干脆两种都提供,让模型自己选?因为多一个工具就多一处它会选错的地方,而且两个工具的失败语义不一样,回灌的提示会互相干扰。宁可一个工具做对一件事。
Key points
- Each approach has its own failure mode: rewrites drift and truncate, patches void on a miscounted line, exact replacement only fails as not-found or not-unique
- Choose exact replacement because it fails teachably, with reasons that map to the model's next action
- A tool's failure message is the model's behavior spec; a message without a next action is useless
- Large mechanical edits belong in a command plus test verification, not a different edit format
- Do not ship two editing tools; inconsistent failure semantics confuse the model
答题要点
- 三种做法各有失败模式:重写会顺手改无关代码且可能被截断,补丁行号一错整块作废,精确替换只会「找不到」或「不唯一」
- 选精确替换的真正理由是失败得可教:失败原因能直接翻译成模型的下一步动作
- 工具的失败信息就是模型的行为规范,写不出下一步动作的失败信息等于没写
- 大规模机械改动不该换编辑方式,而该走命令加测试验证
- 不要同时提供两种编辑工具,失败语义不一致会互相干扰
How does an edit tool detect conflicts, and what should it tell the model when detection fails?编辑工具怎么做冲突检测?检测失败时该给模型什么信息?
Common in ChinaCommon overseasIntermediate#file-editing#conflict-detectionHow to reason about it · think before answering
- This screens for having thought about where the model's snippet came from: a read several turns earlier. In between, the file may have been edited in an IDE, changed by the previous replacement, or swapped by a branch switch. Conflicts are not rare here, they are routine.
- How to break it down: ask what proves the world is still the one you read. Two kinds of evidence. Content matching requires old_string to appear exactly once, so not finding it means the world moved. Version comparison records an mtime or content hash at read time and re-checks before writing.
- The trade-off is the heart of the answer. Version comparison is stricter, catching even changed-and-changed-back, but its failure reason is the file was modified externally, which the model can only respond to by re-reading everything. Content matching is weaker but fails actionably: that snippet is not there, read the file again, or it appears three times, add more context. So use content matching for the model, and optionally layer hashing for human-facing audit.
- Then what to say on failure, three parts: state explicitly that nothing was modified, because models readily assume partial success; give the likely cause, such as inconsistent indentation or a file already changed; and give the next action.
- Finally the discipline: no fuzzy matching. Ignoring whitespace or guessing which occurrence was meant is gambling, and losing means a silent overwrite that tests may not catch for weeks. One extra turn of tokens is far cheaper than a corrupted file.
- Likely follow-up: what about multi-site replacement? Require repeated calls, one site each, or add an explicit replace-all flag that defaults to off and reports how many sites changed. Global replace by default is the most dangerous design here.
分析过程 · 先想清楚再作答
- 这题在筛「有没有想过模型手里那段原文是从哪来的」。它来自几轮之前的一次读取,而在这几轮之间文件可能被编辑器改过、被上一次替换动过、被切分支换掉。所以冲突不是并发编程里的稀有事件,它在 Agent 里是日常。
- 怎么拆:先问「我拿什么证明世界还是我读到的那个世界」。两种证据。一是内容匹配:要求 old_string 在文件里出现且只出现一次,找不到就说明世界变了。二是版本比对:读文件时记下修改时间或内容哈希,写之前再比一遍。
- 两者的取舍是这题的答案核心:版本比对更严格(连「改了又改回来」都能发现),但失败原因是「文件被外部修改」,模型对此无能为力,只能整个重读;内容匹配稍弱,但**失败原因天然可操作**——「找不到那段内容,请先重新读一遍」「那段内容出现了 3 次,请把上下文写长一点」。所以给模型看的那一层用内容匹配,给人看的审计与告警可以叠加哈希比对。
- 然后是「失败时说什么」,三条都不能少:一,明确说出没有改动任何内容(模型很容易误以为部分成功);二,给出可能的原因(缩进或换行不一致、文件已被改过);三,给出下一步动作(先读一遍再重试,或者把上下文写长一点)。
- 最后强调一条纪律:**不许模糊匹配。** 忽略空白、忽略缩进、猜「它大概想改哪一处」都是在赌,赌错的后果是静默覆盖——测试可能还是绿的,问题两周后才浮出来。宁可让它失败一次再重读一次,多花一轮的 token 比改坏一个文件便宜得多。
- 可预期的追问:多处替换怎么办?要求模型多次调用,一次改一处;或者显式加一个「替换全部」的开关,但默认关闭,并在结果里回报改了几处。默认全局替换是最容易出事的设计。
Key points
- The model's snippet comes from a read several turns ago, so conflicts are routine rather than rare
- Two kinds of evidence: content matching with a unique exact snippet, and version comparison via mtime or hash
- Use content matching for the model because its failures are actionable; hashing is stricter but leaves the model nothing to do
- Failure messages need three parts: nothing was changed, the likely cause, and the next action
- Never fuzzy-match; require repeated calls or an explicit opt-in flag for multi-site replacement
答题要点
- 模型手里的原文来自几轮前的读取,所以冲突在 Agent 里是日常而非稀有事件
- 两种证据:内容匹配(原文唯一且完全一致)与版本比对(修改时间或哈希)
- 给模型看的用内容匹配,因为失败原因天然可操作;哈希比对更严格但模型无从下手
- 失败信息三件套:明说没有改动、给出可能原因、给出下一步动作
- 不许模糊匹配;多处替换要求多次调用或显式开关,默认不做全局替换
What constraints do you put on running arbitrary shell commands from an agent, and how do you actually kill one after a timeout?在 Agent 里执行任意 shell 命令,你会加哪些约束?超时之后怎么真正把它杀掉?
Common in ChinaCommon overseasDeep dive#shell-execution#process-managementHow to reason about it · think before answering
- The first half is a checklist; the second half is where most candidates fall apart. Nearly everyone says add a timeout, and almost nobody explains why the process survives the kill.
- For the checklist, organize by what each constraint prevents. Timeouts prevent hangs, with a default and a hard ceiling. Output caps prevent context blowout, counted per stream and applied while reading. A fixed working directory prevents escaping the sandbox. A dangerous-command denylist prevents fat fingers — and say out loud that a denylist is fat-finger protection, not security, since real isolation needs containers, throwaway directories, and no network. Saying that earns points because it shows you know how thin that layer is.
- For the kill, talk about the process tree. You start a shell, the shell starts the real program, and that program may start more children. Killing only the shell orphans the grandchildren, which keep running and keep holding the output pipes, so the close event never fires and the call never settles — the agent hangs.
- The fix has two parts: give the child its own process group at spawn time (detached in Node, setsid semantics on POSIX) and kill the group, using a negative pid or killpg. Send the terminate signal first with a short grace period before the hard kill, since the former lets it clean up temporary files.
- Two more production details people miss: give the child's stdin a null device, or interactive commands hang until the timeout; and keep draining the pipes even after hitting your output cap, because a full pipe blocks the writer and the symptom is chatty commands mysteriously freezing.
- Likely follow-up: how do you prove your kill works? Make the test command keep a shell as parent, for example by backgrounding and waiting, so the difference between killing the shell and killing the group shows up reliably. A single simple command will not reveal it, because the shell often execs itself into the program.
分析过程 · 先想清楚再作答
- 前半句是清单题,后半句是本课最容易露馅的一处:几乎所有人都答得出「加超时」,答不出「为什么杀了却没停」。
- 怎么拆前半句:按「这个约束防的是什么」分四道闸。超时防挂死(默认三十秒,参数可放宽但要有上限),输出上限防上下文被顶满(两条流分别算、边收边截),工作目录防跑出边界,危险命令黑名单防手滑。第四道要主动说清它的性质——**黑名单是防手滑不是防攻击**,绕过办法有一百种,真正的隔离靠容器、临时目录、无网络。主动说这句话是加分项,因为它说明你知道自己那道闸有多厚。
- 后半句要讲进程树。你启动的是一个 shell,shell 再启动真正干活的程序,那个程序还可能启动更多子进程。只杀 shell,孙子进程会变成孤儿继续跑,而且还持着输出管道,于是「子进程关闭」这个事件永远不到,你这次调用的 Promise 永远不 resolve——现象就是「杀了却没停」,整个 Agent 挂在这里。
- 正确做法两步:启动时让子进程自成一个进程组(Node 里是 detached 选项,POSIX 语义是 setsid),杀的时候杀整个进程组(传负的进程号,或者用 killpg)。而且要先发终止信号、留一小段宽限期再发强杀信号——前者让它有机会清理临时文件,后者不给这个机会。
- 生产视角再补两条容易漏的:一,子进程的标准输入要给「忽略」,否则等输入的交互命令会一直挂到超时;二,即使输出超了上限也要继续读管道,不读的话管道满了子进程会被写阻塞,现象是「输出多的命令莫名其妙卡住」。
- 可预期的追问:怎么验证你的杀进程真的有效?让被杀的命令故意留一个 shell 当父进程(例如命令末尾加后台执行再等待),这样「只杀 shell」和「杀进程组」的差别才会稳定出现——只跑一条简单命令是测不出来的,因为 shell 常常直接把自己替换成那个程序。
Key points
- Four gates, each preventing one thing: timeouts for hangs, output caps for context blowout, a fixed cwd for escapes, and a denylist for fat fingers
- A denylist is fat-finger protection, not security; real isolation means containers, throwaway directories, and no network
- Survival after a kill comes from the process tree: orphans still hold the pipes, the close event never fires, and the call never settles
- Spawn the child in its own process group, kill the group, and allow a grace period before the hard kill
- Two easy misses: null out stdin, and keep draining pipes past your cap or the writer blocks
答题要点
- 四道闸各防一件事:超时防挂死、输出上限防上下文顶满、工作目录防越界、黑名单防手滑
- 黑名单是防手滑不是防攻击,真正的隔离靠容器、临时目录与断网
- 杀不掉的根因是进程树:孤儿进程还持着输出管道,close 事件永不到达,调用永不返回
- 做法是启动时让子进程自成进程组,杀时杀整个进程组,并留一段宽限期后再强杀
- 两个易漏点:标准输入给忽略;超了上限也要继续读管道,否则子进程会被写阻塞