Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
cac11a2
feat(daemon): wire Devin callbacks into the ACP query runtime
lsm Aug 22, 2026
e52c66b
fix(daemon): probe the direct pid for ACP process liveness on win32
lsm Aug 22, 2026
6d48344
fix(daemon): stop persisting the raw ACP command in session metadata
lsm Aug 22, 2026
b459143
fix(daemon): address ACP runtime review findings (round 2)
lsm Aug 22, 2026
1a6783e
Merge remote-tracking branch 'origin/dev' into space/acp-split-8-10-f…
lsm Aug 22, 2026
61b2490
fix(daemon): address ACP runtime follow-up review comments
lsm Aug 22, 2026
68e462f
fix(daemon): scope ACP secret redaction and share session env with te…
lsm Aug 22, 2026
f1910b4
fix(daemon): reject ACP approvals whose question exceeds the display …
lsm Aug 22, 2026
4fa678b
fix(daemon): terminate the ACP child when process-tree ownership fails
lsm Aug 22, 2026
7f1bb35
fix(daemon): dedupe aborted tool results and redact terminal approval…
lsm Aug 22, 2026
57dc99e
fix(daemon): bound the in-flight iterator wait after abort
lsm Aug 22, 2026
9d000e9
fix(daemon): redact header arguments and bound abort cleanup fully
lsm Aug 22, 2026
86b84d3
fix(daemon): refine header redaction, stale startup cleanup, abort pa…
lsm Aug 22, 2026
dcad0e0
fix(daemon): scope secret redaction edge cases and refilter in-flight…
lsm Aug 22, 2026
98f1973
fix(daemon): handle attached short options and preserve shell quoting…
lsm Aug 22, 2026
1f49157
fix(daemon): redact user credentials, keep shell syntax in displays, …
lsm Aug 22, 2026
3eb5a75
fix(daemon): gate tool-specific option redaction on the executable
lsm Aug 22, 2026
383d678
fix(daemon): redact URL userinfo and keep unredacted shell scripts ve…
lsm Aug 22, 2026
c669e60
Merge remote-tracking branch 'origin/dev' into space/acp-split-8-10-f…
lsm Aug 22, 2026
229f714
test(daemon): move redactCommandSecrets display tests into their own …
lsm Aug 22, 2026
d687517
fix(daemon): abort late-installed ACP controllers and redact secret s…
lsm Aug 22, 2026
76c6412
fix(daemon): redact shell env assignments and scope --user to curl
lsm Aug 22, 2026
9c8dfcf
fix(daemon): anchor redaction spans and widen shell-script coverage
lsm Aug 22, 2026
ef22bfc
fix(daemon): bind redaction to parsed token spans and scope assignmen…
lsm Aug 22, 2026
e1da07d
fix(daemon): keep assignment phase on any assignment, quote-aware spa…
lsm Aug 22, 2026
ce52a66
fix(daemon): attribute redaction flags to their owning command
lsm Aug 22, 2026
ac8e05b
test(daemon): move digest-only header test into the digest describe
lsm Aug 22, 2026
f48fd1b
fix(daemon): respect command boundaries and end-of-options in redaction
lsm Aug 23, 2026
2306ccf
fix(daemon): redact past recursion cutoff, disclose write size, reap …
lsm Aug 23, 2026
ace08b1
fix(daemon): harden redaction parsing and stop-window process ownership
lsm Aug 23, 2026
eb4bb02
test(daemon): move digest-only redaction tests into the digest describe
lsm Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 267 additions & 3 deletions packages/daemon/src/lib/acp/acp-command.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,275 @@
import { createHash } from 'node:crypto';
import { getAcpCommandIdentity } from '@hyperneo/shared/acp';
import { parseAcpCommandWithSpans } from '@hyperneo/shared/acp';

export { getAcpCommandIdentity, parseAcpCommand } from '@hyperneo/shared/acp';

/* @public - consumed by ACP provider sync in a later stack PR */
const SECRET_ARG_NAME_PATTERN =
/(?:^|[-_])(?:token|secret|password|passphrase|credential|api[-_]?key|bearer)$/i;
Comment on lines +6 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact compound secret environment names

When a terminal command uses a common compound credential name such as env AWS_SECRET_ACCESS_KEY=topsecret agent, the pattern does not match because secret is not the final name component and api-key is the only recognized key suffix. The complete assignment consequently appears in the approval title and persisted resolvedQuestions; environment assignments should recognize secret-bearing components such as SECRET_ACCESS_KEY rather than requiring the credential word at the end.

Useful? React with 👍 / 👎.


const SECRET_HEADER_NAME_PATTERN =
/(?:^|[-_])(?:authorization|auth|cookie|session|token|secret|password|credential|api[-_]?key|bearer)$/i;

const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
const CURL_COMMAND_NAMES = new Set(['curl']);
const ENV_COMMAND_NAMES = new Set(['env', 'export']);
const SHELL_OPERATORS = new Set(['&&', '||', ';', '|', '&', '(', ')']);

function commandBaseName(command: string): string {
return (command.split('/').pop() ?? command).toLowerCase();
}

function isSecretArgName(name: string): boolean {
return SECRET_ARG_NAME_PATTERN.test(name.replace(/^-+/, ''));
}

function isSecretHeaderValue(value: string): boolean {
const headerName = value.split(':', 1)[0]?.trim() ?? '';
return SECRET_HEADER_NAME_PATTERN.test(headerName);
}

function isHeaderArgName(name: string): boolean {
const stripped = name.replace(/^-+/, '');
return /^H$/i.test(stripped) || /^header$/i.test(stripped);
}

function isUserArgName(name: string): boolean {
const stripped = name.replace(/^-+/, '');
return /^u$/i.test(stripped) || /^user$/i.test(stripped);
}

function isSecretEnvAssignment(arg: string): boolean {
const equalsIndex = arg.indexOf('=');
if (equalsIndex <= 0) return false;
return isSecretArgName(arg.slice(0, equalsIndex));
}

export function shellQuote(value: string): string {
return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
}

function displayQuote(token: string): string {
if (!/\s/.test(token)) return token;
if (/[$`&|;<>()*?]/.test(token)) return token;
Comment thread
lsm marked this conversation as resolved.
return shellQuote(token);
}

const URL_USERINFO_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/[^:/@\s]+:)([^@/\s]+)(@.+)$/;

function redactUrlUserinfo(value: string): string {
const match = URL_USERINFO_PATTERN.exec(value);
return match ? `${match[1]}[redacted]${match[3]}` : value;
}

const SCRIPT_SENSITIVITY_PATTERN =
/(^|\s)--?[^\s'"]*(?:token|secret|password|passphrase|credential|api[-_]?key|bearer)|(^|\s)-[hu]\b/i;

function redactShellCommand(script: string, depth: number): string {
if (depth <= 0) {
return SCRIPT_SENSITIVITY_PATTERN.test(script) ? '[redacted script]' : script;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect secret assignments at the recursion cutoff

When shell nesting reaches the cutoff, the sensitivity pattern recognizes credential flags but not secret-shaped environment assignments. For example, sufficiently nesting API_TOKEN=topsecret curl https://a under sh -c returns the remaining script verbatim, exposing the token in the terminal approval title and persisted resolved question. Fresh evidence after the prior cutoff fix is that assignment-only scripts still bypass its conservative fallback; include credential assignments in the cutoff detection.

Useful? React with 👍 / 👎.

}
try {
const parsed = parseAcpCommandWithSpans(script);
const tokens = [parsed.command, ...parsed.args];
const redactedArgs = redactCommandSecrets(parsed.command, parsed.args, depth - 1, {
shellScript: true,
});
const redactedCommand = isSecretEnvAssignment(parsed.command)
? `${parsed.command.slice(0, parsed.command.indexOf('='))}=[redacted]`
: parsed.command;
const redactedTokens = [redactedCommand, ...redactedArgs];
let result = script;
let changed = false;
for (let index = redactedTokens.length - 1; index >= 0; index--) {
if (redactedTokens[index] === tokens[index]) continue;
const span = parsed.rawSpans[index];
if (!span) {
changed = false;
break;
}
const rawText = result.slice(span.start, span.end);
const at = rawText.indexOf(tokens[index]);
if (at >= 0) {
result =
result.slice(0, span.start) +
rawText.slice(0, at) +
redactedTokens[index] +
rawText.slice(at + tokens[index].length) +
result.slice(span.end);
} else {
result = result.slice(0, span.start) + redactedTokens[index] + result.slice(span.end);
}
changed = true;
}
if (changed) return result;
if (redactedTokens.some((token, index) => token !== tokens[index])) {
return redactedTokens.map(displayQuote).join(' ');
}
return script;
} catch {
return script;
Comment on lines +108 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact shell scripts when parsing fails

When a valid shell script contains an unmatched quote inside a trailing comment, such as sh -c 'some-agent --token topsecret # "', the shell executes the command because the quote is commented out, but this parser still treats it as unmatched and the catch branch returns the complete script verbatim. The credential is therefore included in the terminal approval title and persisted in resolvedQuestions; parsing failures should use the conservative sensitivity fallback rather than expose the raw script.

Useful? React with 👍 / 👎.

}
}

export function redactCommandSecrets(
command: string,
args: string[],
depth = 3,
options: { shellScript?: boolean } = {}
): string[] {
const isEnv = ENV_COMMAND_NAMES.has(commandBaseName(command));
const assignmentsAllowed = isEnv || options.shellScript === true;
let inLeadingAssignments =
options.shellScript === true ? command.indexOf('=') > 0 || isEnv : true;
let currentCommand = command;
let awaitingCommandWord = options.shellScript === true || isEnv;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Follow credential consumers launched through command wrappers

For wrapper executables other than env, awaitingCommandWord starts false and currentCommand remains the wrapper forever. Thus common requests such as sudo curl -H 'Authorization: Bearer topsecret' /a or command curl -u alice:topsecret /a retain the credential in the approval title and persisted resolved question. Fresh evidence after the env wrapper fix is that the same bypass remains for other command-launching wrappers; attribute their wrapped executable before applying tool-specific redaction.

Useful? React with 👍 / 👎.

let endOfOptions = false;
const redacted: string[] = [];
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (options.shellScript === true && (SHELL_OPERATORS.has(arg) || /[;&|]$/.test(arg))) {
Comment thread
lsm marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reset command tracking across all shell boundaries

The parsed token stream discards newlines and this condition recognizes only standalone or trailing operator tokens, so valid scripts such as echo ok\ncurl -H 'Authorization: Bearer topsecret' and echo ok;curl -u alice:topsecret never attribute the later flags to curl. Their credentials remain in the approval title and persisted resolved question. Fresh evidence beyond the prior adjacent-separator fix is that multiline boundaries and a command word attached after the separator are still not tracked.

Useful? React with 👍 / 👎.

inLeadingAssignments = true;
endOfOptions = false;
awaitingCommandWord = true;
const word = arg.replace(/[;&|]+$/, '');
if (word && !SHELL_OPERATORS.has(word)) currentCommand = word;
redacted.push(arg);
continue;
Comment thread
lsm marked this conversation as resolved.
}
if (arg === '--') {
endOfOptions = true;
redacted.push(arg);
continue;
}
if (endOfOptions) {
redacted.push(redactUrlUserinfo(arg));
continue;
}
if (!arg.startsWith('-')) {
const looksLikeUrl = /^[a-z][a-z0-9+.-]*:/i.test(arg);
if (options.shellScript === true && !looksLikeUrl && /[;&|]/.test(arg)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve quoted assignment-shaped command data

This textual operator scan has no quote context, so a semicolon inside an ordinary quoted argument reopens assignment handling. For example, sh -c "rm 'x;API_TOKEN=one'" is displayed as deleting x;API_TOKEN=[redacted], and changing one to two yields the same command digest. Fresh evidence after assignment-position scoping is that quoted filenames containing a separator still collapse, hiding behavior-changing input from the approval; only treat unquoted shell operators as command boundaries.

Useful? React with 👍 / 👎.

inLeadingAssignments = true;
endOfOptions = false;
awaitingCommandWord = true;
redacted.push(
arg.replace(/(^|[;&|])([A-Za-z_][\w-]*)=([^;&|\s]*)/g, (whole, pre, name) =>
isSecretEnvAssignment(`${name}=`) ? `${pre}${name}=[redacted]` : whole
)
);
continue;
}
const isAssignmentShaped = arg.indexOf('=') > 0;
if (inLeadingAssignments && assignmentsAllowed && isSecretEnvAssignment(arg)) {
redacted.push(`${arg.slice(0, arg.indexOf('='))}=[redacted]`);
Comment thread
lsm marked this conversation as resolved.
} else {
if (!isAssignmentShaped) {
inLeadingAssignments = false;
Comment thread
lsm marked this conversation as resolved.
if (awaitingCommandWord) {
currentCommand = arg;
awaitingCommandWord = false;
}
}
redacted.push(redactUrlUserinfo(arg));
Comment thread
lsm marked this conversation as resolved.
}
continue;
}
const tokenCommandName = commandBaseName(currentCommand);
const tokenCurl = CURL_COMMAND_NAMES.has(tokenCommandName);
const tokenShell = SHELL_COMMAND_NAMES.has(tokenCommandName);
if (tokenCurl && /^-[a-z]*$/i.test(arg)) {
const letters = arg.slice(1).toLowerCase();
const valueFlag = letters.charAt(letters.length - 1);
const next = args[index + 1];
if (
(valueFlag === 'h' || valueFlag === 'u') &&
next !== undefined &&
!next.startsWith('--')
) {
if (valueFlag === 'u' || isSecretHeaderValue(next)) {
redacted.push(arg, '[redacted]');
index++;
continue;
}
}
redacted.push(arg);
continue;
}
if (tokenCurl && /^-[a-z]*[hu]/i.test(arg)) {
const lowered = arg.toLowerCase();
let carrierIndex = -1;
for (let scan = 1; scan < lowered.length; scan++) {
const ch = lowered[scan];
if (ch === 'h' || ch === 'u') {
carrierIndex = scan;
break;
}
if (!/[a-z]/i.test(ch)) break;
}
if (carrierIndex > 0) {
const value = arg.slice(carrierIndex + 1);
const secret = lowered[carrierIndex] === 'u' || isSecretHeaderValue(value);
if (secret && value) {
redacted.push(`${arg.slice(0, carrierIndex + 1)}[redacted]`);
continue;
}
}
}
if (tokenShell && /^-[a-z]*c[a-z]*$/i.test(arg)) {
const next = args[index + 1];
if (next !== undefined) {
redacted.push(arg, redactShellCommand(next, depth));
index++;
continue;
}
}
if (tokenShell && /^-[a-z]*c/i.test(arg)) {
const cIndex = arg.toLowerCase().lastIndexOf('c');
const attachedScript = arg.slice(cIndex + 1);
if (attachedScript && !/[a-zA-Z]/.test(attachedScript)) {
redacted.push(`${arg.slice(0, cIndex + 1)}${redactShellCommand(attachedScript, depth)}`);
continue;
}
}
const equalsIndex = arg.indexOf('=');
if (equalsIndex >= 0) {
const name = arg.slice(0, equalsIndex);
Comment thread
lsm marked this conversation as resolved.
const value = arg.slice(equalsIndex + 1);
if (isSecretArgName(name)) {
redacted.push(`${name}=[redacted]`);
} else if (tokenCurl && isHeaderArgName(name) && isSecretHeaderValue(value)) {
redacted.push(`${name}=[redacted]`);
} else if (tokenCurl && isUserArgName(name)) {
redacted.push(`${name}=[redacted]`);
} else {
const redactedValue = redactUrlUserinfo(value);
redacted.push(redactedValue === value ? arg : `${name}=${redactedValue}`);
}
continue;
}
const next = args[index + 1];
if (isSecretArgName(arg) && next !== undefined && !next.startsWith('--')) {
Comment on lines +248 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact inline curl cookie arguments

When curl receives inline cookies through -b session=topsecret or --cookie session=topsecret, neither the generic secret-name check nor the curl-specific header/user checks consume the cookie value, so it remains in the approval title and persisted resolved question. The inspected curl 8.5.0 curl --help all documents -b, --cookie <data|filename> Send cookies from string/file; detect inline cookie data and redact it while retaining behavior-changing filename operands.

Useful? React with 👍 / 👎.

redacted.push(arg, '[redacted]');
Comment thread
lsm marked this conversation as resolved.
index++;
Comment thread
lsm marked this conversation as resolved.
Comment thread
lsm marked this conversation as resolved.
continue;
Comment thread
lsm marked this conversation as resolved.
}
if (tokenCurl && isHeaderArgName(arg) && next !== undefined && isSecretHeaderValue(next)) {
redacted.push(arg, '[redacted]');
index++;
continue;
}
if (tokenCurl && isUserArgName(arg) && next !== undefined && !next.startsWith('--')) {
redacted.push(arg, '[redacted]');
index++;
continue;
}
redacted.push(arg);
}
Comment thread
lsm marked this conversation as resolved.
return redacted;
}

export function getAcpCommandIdentityDigest(commandLine: string): string {
return createHash('sha256').update(getAcpCommandIdentity(commandLine)).digest('hex');
const { command, args } = parseAcpCommandWithSpans(commandLine);
const identity = JSON.stringify([command, ...redactCommandSecrets(command, args)]);
return createHash('sha256').update(identity).digest('hex');
}

const ACP_SAFE_ENV_KEYS = [
Expand Down
12 changes: 12 additions & 0 deletions packages/daemon/src/lib/acp/acp-process-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ export const basicAcpProcessTreeOwner: AcpProcessTreeOwner = (child) => ({
},
});

export function acpProcessGroupAlive(
pid: number,
platform: NodeJS.Platform = process.platform
): boolean {
try {
process.kill(platform === 'win32' ? pid : -pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code !== 'ESRCH';
Comment thread
lsm marked this conversation as resolved.
}
}

export function getAcpProcessTreeOwner(): Promise<AcpProcessTreeOwner> {
return Promise.resolve(basicAcpProcessTreeOwner);
}
Loading
Loading