Repository navigation
feat(daemon): wire Devin callbacks into the ACP query runtime #2711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
cac11a2
e52c66b
6d48344
b459143
1a6783e
61b2490
68e462f
f1910b4
4fa678b
7f1bb35
57dc99e
9d000e9
86b84d3
dcad0e0
98f1973
1f49157
3eb5a75
383d678
c669e60
229f714
d687517
76c6412
9c8dfcf
ef22bfc
e1da07d
ce52a66
ac8e05b
f48fd1b
2306ccf
ace08b1
eb4bb02
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| } | ||
|
|
||
| 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; | ||
|
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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When shell nesting reaches the cutoff, the sensitivity pattern recognizes credential flags but not secret-shaped environment assignments. For example, sufficiently nesting 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a valid shell script contains an unmatched quote inside a trailing comment, such as 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For wrapper executables other than 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))) { | ||
|
lsm marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The parsed token stream discards newlines and this condition recognizes only standalone or trailing operator tokens, so valid scripts such as 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; | ||
|
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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This textual operator scan has no quote context, so a semicolon inside an ordinary quoted argument reopens assignment handling. For example, 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]`); | ||
|
lsm marked this conversation as resolved.
|
||
| } else { | ||
| if (!isAssignmentShaped) { | ||
| inLeadingAssignments = false; | ||
|
lsm marked this conversation as resolved.
|
||
| if (awaitingCommandWord) { | ||
| currentCommand = arg; | ||
| awaitingCommandWord = false; | ||
| } | ||
| } | ||
| redacted.push(redactUrlUserinfo(arg)); | ||
|
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); | ||
|
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When curl receives inline cookies through Useful? React with 👍 / 👎. |
||
| redacted.push(arg, '[redacted]'); | ||
|
lsm marked this conversation as resolved.
|
||
| index++; | ||
|
lsm marked this conversation as resolved.
lsm marked this conversation as resolved.
|
||
| continue; | ||
|
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); | ||
| } | ||
|
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 = [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a terminal command uses a common compound credential name such as
env AWS_SECRET_ACCESS_KEY=topsecret agent, the pattern does not match becausesecretis not the final name component andapi-keyis the only recognized key suffix. The complete assignment consequently appears in the approval title and persistedresolvedQuestions; environment assignments should recognize secret-bearing components such asSECRET_ACCESS_KEYrather than requiring the credential word at the end.Useful? React with 👍 / 👎.