Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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;

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();
Comment on lines +17 to +18

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 Normalize Windows executable names before tool-specific redaction

On Windows, commands such as C:\Windows\System32\curl.exe -H "Authorization: Bearer topsecret" https://api never match CURL_COMMAND_NAMES because this helper only splits on / and retains the .exe suffix. The authorization value is therefore returned unchanged; the same issue prevents bash.exe -c ... from receiving nested-script redaction. Normalize Windows path separators and executable suffixes before comparing command names.

Useful? React with 👍 / 👎.

}

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;
return shellQuote(token);
}

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

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 URL passwords when the username is empty

For a valid userinfo URL such as curl https://:topsecret@example.test/a, the username subpattern requires at least one character, so redactUrlUserinfo returns the entire URL unchanged and the password remains visible in the approval output and identity. Empty usernames are commonly used when an API token occupies the password field; allow the username capture to be empty while still requiring the separating colon.

Useful? React with 👍 / 👎.


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;
Comment on lines +67 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect env and URL credentials at the recursion cutoff

Once nested sh -c processing reaches the default recursion limit, the fallback pattern recognizes only secret-shaped flags and -h/-u. A deeper leaf such as API_TOKEN=topsecret curl /a or curl https://user:topsecret@host/a therefore passes through verbatim, even though shallower versions are redacted. Include the supported environment-assignment and URL-userinfo forms in the cutoff check, or conservatively redact the remaining script.

Useful? React with 👍 / 👎.

}
try {
const parsed = parseAcpCommandWithSpans(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 Preserve newline command boundaries while redacting shell scripts

For a multiline script such as echo ok\ncurl -H 'Authorization: Bearer topsecret' https://api, this tokenizer discards the newline as ordinary whitespace, so the redaction state never recognizes curl as a new command and returns the authorization header unchanged. Literal newlines are command separators in these supported shells; preserve them as control operators or use shell-aware tokenization before applying command-specific redaction.

Useful? React with 👍 / 👎.

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 sensitive scripts when nested parsing fails

When an outer ACP command is valid but its shell script is malformed or unsupported by this tokenizer—for example an inner script containing curl --token topsecret '—the catch returns the complete script unchanged. Agent-generated commands can therefore expose the exact credential in the redactor output precisely when parsing fails; apply the conservative sensitivity fallback here instead of returning raw text.

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 Keep curl as the current command after positional arguments

When redacting a nested shell script such as sh -c 'curl https://api -H "Authorization: Bearer topsecret"', awaitingCommandWord starts true even though parsed.command is already curl, so the URL replaces currentCommand. The later -H or -u is consequently treated as belonging to the URL and the credential is returned unchanged for an approval display. Only wait for a command word when the parsed command is an assignment or an actual wrapper such as env.

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))) {
inLeadingAssignments = true;
endOfOptions = false;
awaitingCommandWord = true;
const word = arg.replace(/[;&|]+$/, '');
if (word && !SHELL_OPERATORS.has(word)) currentCommand = word;
redacted.push(arg);
continue;
}
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)) {
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]`);
} else {
if (!isAssignmentShaped) {
inLeadingAssignments = false;
if (awaitingCommandWord) {
currentCommand = arg;
awaitingCommandWord = false;
}
}
redacted.push(redactUrlUserinfo(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 Redact URL userinfo inside environment assignments

For a valid wrapper command such as env DATABASE_URL=postgresql://alice:topsecret@db/app agent, the assignment name is not secret-shaped and this call applies the anchored URL matcher to the entire NAME=value token rather than to its value. It consequently returns the database password unchanged even though URL userinfo is otherwise explicitly redacted. Split assignment-shaped positional tokens and apply redactUrlUserinfo to their right-hand side.

Useful? React with 👍 / 👎.

}
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('--')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Consume dash-prefixed curl user values

When curl credentials themselves begin with two hyphens, as in curl -u --topsecret: https://api, this guard leaves them unredacted and treats them as another option in the identity. Curl actually consumes such an option-looking token as the value of -u—running the installed curl -u --version prompts for the password of user --version rather than printing the version—so known curl value-taking flags should redact their next token regardless of its prefix.

Useful? React with 👍 / 👎.

) {
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;
Comment on lines +196 to +212

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stop interpreting curl data values as clustered user flags

The scan treats the first u or h anywhere in an attached alphabetic curl argument as a credential-carrying option, without accounting for an earlier option that consumes the remainder as its value. For example, the curl manual defines -d as request data, but curl -duser=delete-all /a is rewritten to -du[redacted]; different request bodies then collapse to the same digest and the approval display hides a material command effect. Parse curl's value-taking short options before looking for clustered -u or -H.

Useful? React with 👍 / 👎.

}
}
}
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);
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('--')) {
redacted.push(arg, '[redacted]');
index++;
continue;
}
if (tokenCurl && isHeaderArgName(arg) && next !== undefined && isSecretHeaderValue(next)) {
redacted.push(arg, '[redacted]');
index++;
continue;
}
if (tokenCurl && isUserArgName(arg) && next !== undefined && !next.startsWith('--')) {

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 curl proxy-user credentials

For curl --proxy-user alice:topsecret https://api, this exact-name check rejects --proxy-user, so the proxy password remains in the redacted approval output and changes the identity digest. The installed curl --help all identifies -U, --proxy-user <user:password> as carrying the proxy username and password; treat this long option as a curl credential carrier just like --user.

Useful? React with 👍 / 👎.

redacted.push(arg, '[redacted]');
index++;
continue;
}
redacted.push(arg);
}
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
Loading