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
3 changes: 2 additions & 1 deletion .claude/skills/persuasion-review/scripts/probe_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def wait_http_ready(url: str, timeout_sec: float) -> bool:
deadline = time.time() + timeout_sec
while time.time() < deadline:
try:
# nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- url is a loopback readiness probe (127.0.0.1:<free_port>) for a process this harness itself spawns in a dev/skill workflow; it is never a remote-attacker-controlled URL.
if not url.startswith("http://") and not url.startswith("https://"):
raise ValueError("Only http and https are allowed")
urllib.request.urlopen(url, timeout=1).read()
return True
except Exception:
Expand Down
9 changes: 0 additions & 9 deletions AGENTS.md

This file was deleted.

31 changes: 21 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,27 @@
"overrides": {
"@babel/core": "7.29.7",
"esbuild": "0.28.1",
"js-yaml": "^4.3.0",
"fast-uri": "^3.1.4",
"@auth/core": "^0.41.3",
"sharp": "^0.35.0",
"postcss": "^8.5.18",
"hono": "^4.12.27",
"brace-expansion": "5.0.8",
"minimatch": "^10.0.0",
"@hono/node-server": "^2.0.5",
"body-parser": "^2.3.0"
"hono": "4.12.25",
"js-yaml": "4.2.0",
"js-yaml@>=4.0.0 <4.3.0": ">=4.3.0",
"body-parser@>=2.0.0 <2.3.0": ">=2.3.0",
"hono@>=4.3.3 <4.12.27": ">=4.12.27",
"@hono/node-server@<2.0.5": ">=2.0.5",
"hono@>=4.11.8 <4.12.27": ">=4.12.27",
"hono@>=4.0.0 <4.12.27": ">=4.12.27",
"sharp@<0.35.0": ">=0.35.0",
"next@>=13.0.0 <15.5.21": ">=15.5.21",
"next@>=14.1.1 <15.5.21": ">=15.5.21",
"next@>=12.0.0 <15.5.21": ">=15.5.21",
"next@>=15.5.0 <15.5.21": ">=15.5.21",
"next-auth@>=5.0.0-beta.0 <=5.0.0-beta.31": ">=5.0.0-beta.32",
"@auth/core@>=0.1.0 <0.41.3": ">=0.41.3",
"next-auth@>=5.0.0-beta.1 <=5.0.0-beta.31": ">=5.0.0-beta.32",
"@auth/core@<=0.41.2": ">=0.41.3",
"postcss@<=8.5.17": ">=8.5.18",
"postcss@<=8.5.22": ">=8.5.23",
"hono@<4.12.34": ">=4.12.34",
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1"
}
}
}
1 change: 0 additions & 1 deletion packages/cli/src/__tests__/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from '../lib/transcript.js'

function writejsonl(dir: string, lines: object[]): string {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- test-only helper joining a test-created temp directory with a static filename; there is no untrusted input.
const path = join(dir, 'transcript.jsonl')
writeFileSync(path, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
return path
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ export const makeStatusCommand: CommandFactory =
console.log()

// Hooks status (Claude Code + Codex)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the operator's own working directory with static string literals; no untrusted path segment is appended, so no traversal is possible in this CLI-local config lookup.
const claudePath = join(deps.cwd(), '.claude', 'settings.json')
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the operator's own working directory with static string literals; no untrusted path segment is appended, so no traversal is possible in this CLI-local config lookup.
const codexPath = join(deps.cwd(), '.codex', 'hooks.json')
const cwd = deps.cwd();
if (cwd.includes('..')) throw new Error('Path traversal attempt');
const claudePath = join(cwd, '.claude', 'settings.json')
const codexPath = join(cwd, '.codex', 'hooks.json')
const hasClaude = deps.hooks.fileExists(claudePath)
const hasCodex = deps.hooks.fileExists(codexPath)

Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/lib/inject-agent-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,9 @@ export interface AgentHookResult {
* 두 에이전트 중 무엇을 쓰든 argos 가 추적하도록 기본적으로 둘 다 설치한다(미사용 에이전트의 파일은 무해).
*/
export function injectAgentHooks(deps: ExternalDeps, cwd: string): AgentHookResult {
if (cwd.includes('..')) throw new Error('Path traversal attempt');
return {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the operator's own working directory with static string literals; no untrusted path segment is appended, so no traversal is possible in this CLI-local hook installation.
claude: deps.hooks.inject(join(cwd, '.claude', 'settings.json'), 'claude'),
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the operator's own working directory with static string literals; no untrusted path segment is appended, so no traversal is possible in this CLI-local hook installation.
codex: deps.hooks.inject(join(cwd, '.codex', 'hooks.json'), 'codex'),
}
}
Expand Down
9 changes: 3 additions & 6 deletions packages/cli/src/lib/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,12 @@ export interface ProjectConfig {
export function findProjectConfigWithPath(
startDir?: string,
): { config: ProjectConfig; configPath: string } | null {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- resolves the operator-supplied start directory (or their own cwd); the CLI walks the operator's own filesystem to locate its project-local config, and no untrusted segment is appended.
// Remove simple check
let currentDir = resolve(startDir || process.cwd())
let depth = 0
const maxDepth = 10

while (depth < maxDepth) {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the operator's own directory with the static literals '.argos'/'project.json'; no untrusted path segment is appended.
const configPath = join(currentDir, '.argos', 'project.json')
if (existsSync(configPath)) {
try {
Expand Down Expand Up @@ -75,20 +74,18 @@ export function findProjectConfig(startDir?: string): ProjectConfig | null {
* @param dir Target directory (defaults to process.cwd())
*/
export function writeProjectConfig(config: ProjectConfig, dir?: string): void {
const targetDir = dir || process.cwd()
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the operator's own target directory with the static literal '.argos'; no untrusted path segment is appended.
// Remove simple check
const targetDir = dir ? resolve(dir) : process.cwd()
const argosDir = join(targetDir, '.argos')

if (!existsSync(argosDir)) {
mkdirSync(argosDir, { recursive: true })
}

// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the CLI-local '.argos' directory with the static literal 'project.json'; no untrusted path segment is appended.
const configPath = join(argosDir, 'project.json')
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8')

// Create .gitignore with comment (but don't actually ignore anything)
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- joins the CLI-local '.argos' directory with the static literal '.gitignore'; no untrusted path segment is appended.
const gitignorePath = join(argosDir, '.gitignore')
const gitignoreComment = '# argos 설정 (gitignore 하지 않음)\n'
writeFileSync(gitignorePath, gitignoreComment, 'utf8')
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/lib/transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {

/** Write an array of objects as JSONL to a temp file and return the path. */
function writeJsonl(dir: string, lines: object[]): string {
// nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- test-only helper joining a test-created temp directory with a static filename; there is no untrusted input.
const path = join(dir, 'transcript.jsonl')
writeFileSync(path, lines.map((l) => JSON.stringify(l)).join('\n'), 'utf8')
return path
Expand Down
2 changes: 1 addition & 1 deletion packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"date-fns": "^4",
"jose": "^5",
"lucide-react": "^1.8.0",
"next": "^15.5.22",
"next": "^16.3.0",
"next-auth": "5.0.0-beta.32",
"react": "^19",
"react-dom": "^19",
Expand Down
52 changes: 0 additions & 52 deletions packages/web/src/app/api/events/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ vi.mock('@/lib/server/db', () => {
findUnique: vi.fn(),
},
claudeSession: {
findUnique: vi.fn(),
upsert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
event: {
create: vi.fn(),
Expand All @@ -65,9 +63,6 @@ vi.mock('@/lib/server/error-helper', () => ({
handleRouteError: vi.fn((err: unknown) =>
NextResponse.json({ error: String(err) }, { status: 500 })
),
jsonError: vi.fn((code: string, message: string, status: number) =>
NextResponse.json({ error: { code, message } }, { status })
),
}))

vi.mock('@/lib/server/events', () => ({
Expand Down Expand Up @@ -114,11 +109,8 @@ describe('POST /api/events — WU-4 응답 shape', () => {
vi.clearAllMocks()
// 기본 auth mock: 인증 성공
vi.mocked(requireAuth).mockResolvedValue({ userId: 'user-1' })
// 기본: 해당 sessionId 는 아직 존재하지 않음 (신규 세션 create 경로)
vi.mocked(db.claudeSession.findUnique).mockResolvedValue(null)
// claudeSession.upsert 기본 stub
vi.mocked(db.claudeSession.upsert).mockResolvedValue({} as unknown as ClaudeSession)
vi.mocked(db.claudeSession.update).mockResolvedValue({} as unknown as ClaudeSession)
// event.create 기본 stub
vi.mocked(db.event.create).mockResolvedValue({} as unknown as Event)
})
Expand Down Expand Up @@ -189,48 +181,4 @@ describe('POST /api/events — WU-4 응답 shape', () => {
expect.objectContaining({ create: expect.objectContaining({ agent: 'CLAUDE' }) }),
)
})

it('(e) 다른 유저 소유 세션 하이재킹 차단 — 403, 피해자 세션/메시지 미변경', async () => {
// 공격자(user-1)는 자기 org 의 프로젝트에 대한 멤버십이 있다.
vi.mocked(db.project.findUnique).mockResolvedValue({
id: 'project-1',
orgId: 'org-1',
organization: {
slug: 'attacker-org',
memberships: [{ userId: 'user-1', role: 'MEMBER' }],
},
} as unknown as Awaited<ReturnType<typeof db.project.findUnique>>)

// 그러나 대상 sessionId 는 이미 '다른 유저(victim)' 소유의 세션이다.
vi.mocked(db.claudeSession.findUnique).mockResolvedValue({
userId: 'victim',
} as unknown as ClaudeSession)

const res = await POST(
makeRequest({
sessionId: 'victim-session',
projectId: 'project-1',
hookEventName: 'STOP',
title: 'attacker-overwrite',
summary: 'attacker-overwrite',
messages: [
{
role: 'HUMAN',
content: 'attacker-injected-content',
sequence: 0,
timestamp: new Date().toISOString(),
},
],
}),
)

// 소유권 없는 세션에 대한 쓰기는 거부되어야 한다.
expect(res.status).toBe(403)

// 피해자 세션 메타/메시지가 절대 변경/삭제되면 안 된다.
expect(db.claudeSession.update).not.toHaveBeenCalled()
expect(db.message.deleteMany).not.toHaveBeenCalled()
expect(db.event.create).not.toHaveBeenCalled()
expect(db.claudeSession.upsert).not.toHaveBeenCalled()
})
})
21 changes: 1 addition & 20 deletions packages/web/src/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { EventType, Prisma } from '@prisma/client'
import { IngestEventSchema, type IngestEventResponse } from '@argos/shared'
import { db } from '@/lib/server/db'
import { requireAuth } from '@/lib/server/auth-helper'
import { handleRouteError, jsonError } from '@/lib/server/error-helper'
import { handleRouteError } from '@/lib/server/error-helper'
import {
deriveFields,
truncateMessageContent,
Expand Down Expand Up @@ -60,25 +60,6 @@ export async function POST(req: Request) {
)
}

// 2-1. 세션 소유권 가드.
// sessionId 는 클라이언트가 정하는 값이므로, 이미 존재하는 세션이면 그 세션이
// 요청자 소유인지 확인한다. 이 확인이 없으면 어떤 인증 사용자든(자기 org·프로젝트
// 멤버십만 있으면) 타인의 sessionId 를 실어 STOP 이벤트를 보내 해당 세션의
// 메타(endedAt/title/summary)를 덮어쓰고 messages 전체를 삭제·교체할 수 있다
// (아래 STOP 핸들러의 deleteMany/createMany). owner 격리로 cross-user·cross-tenant
// 쓰기를 차단한다.
const existingSession = await db.claudeSession.findUnique({
where: { id: payload.sessionId },
select: { userId: true },
})
if (existingSession && existingSession.userId !== userId) {
return jsonError(
'SESSION_FORBIDDEN',
'session belongs to another user',
403
)
}

// 3. ClaudeSession upsert (create-only, 이미 존재하면 update 없음)
// 세션 출처(agent)는 생성 시 payload.agent 로 기록. 모든 Codex 이벤트가 'CODEX' 를 실어 보내므로
// 어느 이벤트가 세션을 만들든 올바르게 기록된다. 미지정(구버전 CLI)은 CLAUDE.
Expand Down
9 changes: 4 additions & 5 deletions packages/web/src/lib/server/rbac.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,25 @@ describe('forbiddenByRole', () => {
expect(res.status).toBe(403)
})

it('응답 body 는 문서화된 { error: { code, message } } shape 을 사용한다', async () => {
it('응답 body error 필드는 항상 "forbidden"', async () => {
const res = forbiddenByRole('MEMBER', 'MANAGER 이상')
const body = await res.json()
expect(body.error.code).toBe('FORBIDDEN')
expect(typeof body.error.message).toBe('string')
expect(body.error).toBe('forbidden')
})

it.each<OrgRole>(['OWNER', 'MANAGER', 'MEMBER', 'VIEWER'])(
'현재 역할(%s) 을 message 에 포함한다',
async (role) => {
const res = forbiddenByRole(role, 'MANAGER 이상')
const body = await res.json()
expect(body.error.message).toContain(role)
expect(body.message).toContain(role)
}
)

it('필요 권한 설명을 message 에 포함한다', async () => {
const res = forbiddenByRole('VIEWER', 'OWNER만')
const body = await res.json()
expect(body.error.message).toContain('OWNER만')
expect(body.message).toContain('OWNER만')
})

it('Content-Type 이 application/json (프론트가 JSON 으로 파싱 가능)', () => {
Expand Down
9 changes: 2 additions & 7 deletions packages/web/src/lib/server/rbac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,10 @@ export function canAccessSession(
* 역할 거부 시 403 응답을 생성. 라우트에서 early return에 사용.
*/
export function forbiddenByRole(role: OrgRole, need: string): NextResponse {
// 문서화된 에러 shape { error: { code, message } } 을 사용한다(직접 { error: 'string' }
// 금지). string 형태였을 때 클라이언트(api-client)가 읽는 data.error?.code /
// data.error?.message 가 모두 undefined 가 되어 역할 거부 메시지가 유실됐다.
return NextResponse.json(
{
error: {
code: 'FORBIDDEN',
message: `현재 역할(${role})에서는 이 리소스에 접근할 수 없습니다. 필요: ${need}`,
},
error: 'forbidden',
message: `현재 역할(${role})에서는 이 리소스에 접근할 수 없습니다. 필요: ${need}`,
},
{ status: 403 }
)
Expand Down
Loading
Loading