Skip to content
Merged
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
219 changes: 219 additions & 0 deletions Sources/agentd/ActivitySummary.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// SPDX-License-Identifier: BUSL-1.1

import Foundation

struct ActivityOptions: Equatable {
var sinceHours: Double
var batchDirectory: URL

init(
sinceHours: Double = 24,
batchDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".evalops/agentd/batches")
) {
self.sinceHours = sinceHours
self.batchDirectory = batchDirectory
}

static func parse(_ arguments: [String]) throws -> ActivityOptions {
var options = ActivityOptions()
var index = 0
while index < arguments.count {
let flag = arguments[index]
switch flag {
case "--since":
index += 1
guard index < arguments.count,
let hours = Double(arguments[index]),
hours > 0
else {
throw DiagnosticCLIError.usage("--since requires a positive hour value")
}
options.sinceHours = hours
case "--batch-dir":
index += 1
guard index < arguments.count else {
throw DiagnosticCLIError.usage("--batch-dir requires a path")
}
options.batchDirectory = URL(fileURLWithPath: arguments[index])
case "--help", "-h":
throw DiagnosticCLIError.usage("")
default:
throw DiagnosticCLIError.usage("unknown activity flag '\(flag)'")
}
index += 1
}
return options
}
}

struct ActivitySummary: Codable, Sendable {
let generatedAt: Date
let since: Date
let until: Date
let batchDirectory: String
let batchCount: Int
let nonemptyBatchCount: Int
let frameCount: Int
let droppedCounts: DropCounts
let droppedReasonCounts: [String: Int]
let apps: [ActivityAppSummary]
let windows: [ActivityWindowSummary]

static func run(options: ActivityOptions = ActivityOptions(), now: Date = Date()) async throws
-> ActivitySummary
{
try summarize(options: options, now: now)
}

private static func summarize(options: ActivityOptions, now: Date) throws -> ActivitySummary {
let since = now.addingTimeInterval(-options.sinceHours * 3_600)
let files = try batchFiles(in: options.batchDirectory)
var batchCount = 0
var nonemptyBatchCount = 0
var frameCount = 0
var dropped = DropCounts(secret: 0, duplicate: 0, deniedApp: 0, deniedPath: 0)
var droppedReasonCounts: [String: Int] = [:]
var appCounters: [ActivityAppKey: Int] = [:]
var windowCounters: [ActivityWindowKey: ActivityWindowAccumulator] = [:]

for file in files {
guard let batch = try? decodeSubmitBatchRequest(Data(contentsOf: file)).batch else {
continue
}
guard batch.endedAt >= since, batch.startedAt <= now else { continue }

batchCount += 1
if !batch.frames.isEmpty {
nonemptyBatchCount += 1
}
frameCount += batch.frames.count
dropped = dropped.adding(batch.droppedCounts)
for (reason, count) in batch.droppedReasonCounts {
droppedReasonCounts[reason, default: 0] += count
}
for frame in batch.frames {
appCounters[
ActivityAppKey(appName: frame.appName, bundleId: frame.bundleId),
default: 0
] += 1

let documentPath = URLPrivacyRedactor.redactDocumentPath(frame.documentPath)
let key = ActivityWindowKey(
appName: frame.appName,
bundleId: frame.bundleId,
windowTitle: frame.windowTitle,
documentPath: documentPath
)
var accumulator = windowCounters[key] ?? ActivityWindowAccumulator()
accumulator.record(frame)
windowCounters[key] = accumulator
}
}

return ActivitySummary(
generatedAt: now,
since: since,
until: now,
batchDirectory: options.batchDirectory.path,
batchCount: batchCount,
nonemptyBatchCount: nonemptyBatchCount,
frameCount: frameCount,
droppedCounts: dropped,
droppedReasonCounts: droppedReasonCounts.sortedByKey(),
apps: appCounters.map { key, count in
ActivityAppSummary(appName: key.appName, bundleId: key.bundleId, frameCount: count)
}.sorted(),
windows: windowCounters.map { key, accumulator in
ActivityWindowSummary(
appName: key.appName,
bundleId: key.bundleId,
windowTitle: key.windowTitle,
documentPath: key.documentPath,
frameCount: accumulator.frameCount,
firstSeenAt: accumulator.firstSeenAt,
lastSeenAt: accumulator.lastSeenAt
)
}.sorted()
)
}

private static func batchFiles(in directory: URL) throws -> [URL] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
return try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
).filter { $0.pathExtension == "json" }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
}

struct ActivityAppSummary: Codable, Sendable, Equatable, Comparable {
let appName: String
let bundleId: String
let frameCount: Int

static func < (lhs: ActivityAppSummary, rhs: ActivityAppSummary) -> Bool {
if lhs.appName != rhs.appName { return lhs.appName < rhs.appName }
return lhs.bundleId < rhs.bundleId
}
}

struct ActivityWindowSummary: Codable, Sendable, Equatable, Comparable {
let appName: String
let bundleId: String
let windowTitle: String
let documentPath: String?
let frameCount: Int
let firstSeenAt: Date
let lastSeenAt: Date

static func < (lhs: ActivityWindowSummary, rhs: ActivityWindowSummary) -> Bool {
if lhs.windowTitle != rhs.windowTitle { return lhs.windowTitle < rhs.windowTitle }
if lhs.appName != rhs.appName { return lhs.appName < rhs.appName }
return lhs.bundleId < rhs.bundleId
}
}

private struct ActivityAppKey: Hashable {
let appName: String
let bundleId: String
}

private struct ActivityWindowKey: Hashable {
let appName: String
let bundleId: String
let windowTitle: String
let documentPath: String?
}

private struct ActivityWindowAccumulator {
private(set) var frameCount = 0
private(set) var firstSeenAt = Date.distantFuture
private(set) var lastSeenAt = Date.distantPast

mutating func record(_ frame: ProcessedFrame) {
frameCount += 1
firstSeenAt = min(firstSeenAt, frame.capturedAt)
lastSeenAt = max(lastSeenAt, frame.capturedAt)
}
}

extension DropCounts {
fileprivate func adding(_ other: DropCounts) -> DropCounts {
DropCounts(
secret: secret + other.secret,
duplicate: duplicate + other.duplicate,
deniedApp: deniedApp + other.deniedApp,
deniedPath: deniedPath + other.deniedPath,
droppedBackpressure: droppedBackpressure + other.droppedBackpressure
)
}
}

extension Dictionary where Key == String, Value == Int {
fileprivate func sortedByKey() -> [String: Int] {
Dictionary(uniqueKeysWithValues: sorted { $0.key < $1.key })
}
}
Comment thread
haasonsaas marked this conversation as resolved.
10 changes: 9 additions & 1 deletion Sources/agentd/DiagnosticCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ enum DiagnosticProbeRunner {
enum DiagnosticCLI {
static let handledCommands = [
"list-displays", "capture-once", "capture-worker-once", "capture-worker-stream", "selftest",
"help", "--help", "-h",
"activity", "help", "--help", "-h",
]

static func shouldHandle(_ arguments: [String]) -> Bool {
Expand Down Expand Up @@ -137,6 +137,9 @@ enum DiagnosticCLI {
case .selftest:
let payload = await SelftestDiagnostics.run()
try writeJSON(payload, to: nil)
case .activity(let options):
let payload = try await ActivitySummary.run(options: options)
try writeJSON(payload, to: nil)
case .captureWorkerOnce(let options):
let payload = try await CaptureWorkerDiagnostics.run(options: options)
try writeJSON(payload, to: options.out)
Expand Down Expand Up @@ -175,10 +178,12 @@ enum DiagnosticCLI {
Usage:
agentd list-displays
agentd capture-once [--display-id ID] [--no-ocr] [--out PATH]
agentd activity [--since HOURS] [--batch-dir PATH]
agentd selftest

Diagnostic commands emit redacted JSON and never start the menu-bar app.
capture-once uses the normal privacy filters, SecretScrubber, and OCR pipeline.
activity summarizes locally persisted JSON batches without reading encrypted batch files.

"""
}
Expand All @@ -190,6 +195,7 @@ enum DiagnosticCommand: Equatable {
case captureWorkerOnce(CaptureOnceOptions)
case captureWorkerStream(CaptureStreamOptions)
case selftest
case activity(ActivityOptions)

static func parse(_ arguments: [String]) throws -> DiagnosticCommand {
guard let command = arguments.first else { return .help }
Expand All @@ -209,6 +215,8 @@ enum DiagnosticCommand: Equatable {
case "selftest":
guard tail.isEmpty else { throw DiagnosticCLIError.usage("selftest takes no flags") }
return .selftest
case "activity":
return .activity(try ActivityOptions.parse(tail))
default:
throw DiagnosticCLIError.usage("unknown diagnostic command '\(command)'")
}
Expand Down
14 changes: 12 additions & 2 deletions Sources/agentd/Pipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,17 @@ actor FramePipeline {
textSourceCounts[textSource, default: 0] += 1
}

switch evaluateSecretSurfaces(context: ctx, ocrText: ocrResult.text) {
let redactedDocumentPath = URLPrivacyRedactor.redactDocumentPath(ctx.documentPath)
let redactedContext = WindowContext(
bundleId: ctx.bundleId,
appName: ctx.appName,
windowTitle: ctx.windowTitle,
documentPath: redactedDocumentPath,
pid: ctx.pid,
timestamp: ctx.timestamp
)

switch evaluateSecretSurfaces(context: redactedContext, ocrText: ocrResult.text) {
case .dropped(let reason):
recordDrop(reason: "secret.\(reason)", kind: nil)
Log.scrub.warning("frame dropped secret=\(reason, privacy: .public)")
Expand Down Expand Up @@ -889,7 +899,7 @@ actor FramePipeline {
windowTitle: ctx.windowTitle,
documentPath: captureTier == .audit
? ChronicleBehavior.auditDocumentPath(ctx.documentPath)
: ctx.documentPath,
: redactedDocumentPath,
tier: captureTier,
ocrText: ocrText.value,
ocrTextTruncated: ocrText.truncated,
Expand Down
2 changes: 1 addition & 1 deletion Sources/agentd/Submitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ private func encodeSubmitBatchRequest(_ request: SubmitBatchRequest) throws -> D
return try enc.encode(request)
}

private func decodeSubmitBatchRequest(_ data: Data) throws -> SubmitBatchRequest {
func decodeSubmitBatchRequest(_ data: Data) throws -> SubmitBatchRequest {
let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601
return try dec.decode(SubmitBatchRequest.self, from: data)
Expand Down
57 changes: 57 additions & 0 deletions Sources/agentd/URLPrivacyRedactor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: BUSL-1.1

import Foundation

enum URLPrivacyRedactor {
static func redactDocumentPath(_ value: String?) -> String? {
guard let value, !value.isEmpty else { return value }
guard value.contains("://"), var components = URLComponents(string: value) else {
return value
}

var redacted = false
if let queryItems = components.queryItems, !queryItems.isEmpty {
components.queryItems = queryItems.map { item in
guard isSensitiveQueryName(item.name) else { return item }
redacted = true
return URLQueryItem(name: item.name, value: "REDACTED")
}
}
if components.fragment != nil {
components.fragment = "REDACTED"
redacted = true
}

return redacted ? components.string ?? value : value
}

private static func isSensitiveQueryName(_ name: String) -> Bool {
let normalized = name.lowercased()
if exactSensitiveQueryNames.contains(normalized) {
return true
}
return normalized.hasSuffix("_token")
|| normalized.hasSuffix("-token")
|| normalized.contains("secret")
|| normalized.contains("credential")
}

private static let exactSensitiveQueryNames: Set<String> = [
"access_token",
"assertion",
"authuser",
"client_secret",
"code",
"credential",
"id_token",
"oauth_token",
"oauth_verifier",
"prompt",
"refresh_token",
"scope",
"session",
"session_state",
"state",
"token",
]
}
Loading