nyx-agent-core 0.1.0

Implementation-detail config, state, persistence, and scan orchestration for nyx-agent.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! Repository ingestion.
//!
//! Every [`Repo`] belongs to a [`Project`](crate::project::Project) via
//! its `project_id` field; the run dispatcher, sandbox env-builder, and
//! chain runner all consume repos grouped by project, and on-disk
//! workspace state lands under `<state>/projects/<project_id>/repos/
//! <name>/`. See `docs/PROJECT_ENTITY_PLAN.md` for the migration
//! that introduced the FK.
//!
//! The `Repo`, `RepoSource`, and `GitAuth` data shapes live in
//! `nyx-agent-types::repo` so other workspace crates and the TS frontend
//! can name them directly. This module hosts the ingestion-side parser,
//! [`IngestError`], and the per-source backends.
//!
//! Two source kinds are supported:
//!
//! - [`RepoSource::Git`]: shallow clone or fetch into the
//!   project-scoped `<state>/projects/<project_id>/repos/<name>/`
//!   directory. Subsequent runs reuse the checkout via a shallow
//!   `git fetch`.
//! - [`RepoSource::LocalPath`]: read-only snapshot of a directory
//!   already on disk. The snapshot is rebuilt per run and removed at end
//!   of run so concurrent local edits in an IDE never race the scan.
//!
//! Every ingestion path enforces an ownership attestation:
//! [`Repo::i_own_this`] must be `true`. For git sources the attested URL
//! is matched against the on-disk remote after clone. For local-path
//! sources the daemon surfaces the on-disk `.git/config` remote (if
//! present) so the operator can confirm at first run.

use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::config::{RepoConfig, RepoSourceConfig};
use crate::project::ProjectId;

pub mod git;
pub mod local;

pub use git::{validate_token_scopes, GhScopeCheck};
pub use local::SnapshotBackend;
pub use nyx_agent_types::repo::{GitAuth, Repo, RepoSource};

/// Parse a config auth descriptor of the form `<scheme>:<value>`.
///
/// Lives here (not in `nyx-agent-types`) because the error type belongs to
/// the ingestion crate.
pub fn parse_git_auth(raw: &str) -> Result<GitAuth, IngestError> {
    let (scheme, value) =
        raw.split_once(':').ok_or_else(|| IngestError::AuthMalformed { raw: raw.to_string() })?;
    match scheme {
        "ssh-key" => Ok(GitAuth::SshKey(PathBuf::from(value))),
        "token-env" => {
            if value.is_empty() {
                return Err(IngestError::AuthMalformed { raw: raw.to_string() });
            }
            Ok(GitAuth::TokenEnv(value.to_string()))
        }
        "gh-app" => {
            if value.is_empty() {
                return Err(IngestError::AuthMalformed { raw: raw.to_string() });
            }
            Ok(GitAuth::GhApp(value.to_string()))
        }
        other => Err(IngestError::AuthUnknownScheme { scheme: other.to_string() }),
    }
}

/// Build a [`Repo`] from a [`RepoConfig`] entry under a project.
/// Does not perform any IO; ownership is checked at ingestion time
/// via [`ingest`].
pub fn repo_from_config(cfg: &RepoConfig, project_id: ProjectId) -> Result<Repo, IngestError> {
    let source = match &cfg.source {
        RepoSourceConfig::Git { url, branch, auth } => {
            let auth = match auth.as_deref() {
                Some(raw) => Some(parse_git_auth(raw)?),
                None => None,
            };
            RepoSource::Git { url: url.clone(), branch: branch.clone(), auth }
        }
        RepoSourceConfig::LocalPath { path } => RepoSource::LocalPath { path: path.clone() },
    };
    Ok(Repo { name: cfg.name.clone(), source, i_own_this: cfg.i_own_this, project_id })
}

/// Errors returned from repo ingestion.
#[derive(Debug, Error)]
pub enum IngestError {
    #[error(
        "repo `{name}` is not attested: set `i_own_this = true` in nyx-agent.toml after \
         confirming you own the repository"
    )]
    NotAttested { name: String },

    #[error("malformed auth descriptor `{raw}`: expected `<scheme>:<value>`")]
    AuthMalformed { raw: String },

    #[error(
        "unknown auth scheme `{scheme}`: supported schemes are `ssh-key`, `token-env`, `gh-app`"
    )]
    AuthUnknownScheme { scheme: String },

    #[error("env var `{var}` for `token-env:{var}` auth is not set")]
    AuthEnvMissing { var: String },

    #[error(
        "GitHub token from env `{var}` carries write scope `{scope}`; only read-only tokens are \
         accepted"
    )]
    AuthTokenWriteScope { var: String, scope: String },

    #[error("failed to check GitHub token scopes: {source}")]
    AuthScopeCheck {
        #[source]
        source: anyhow::Error,
    },

    #[error(
        "GitHub token scope probe at `{url}` returned HTTP {status}; refusing to clone with an \
         unvetted token (token may be revoked, rate-limited, or GitHub may be degraded)"
    )]
    AuthScopeStatus { url: String, status: u16 },

    #[error("`gh-app` auth is not yet implemented; configure `token-env` or `ssh-key` instead")]
    AuthGhAppUnsupported,

    #[error(
        "`git --version` output `{raw}` could not be parsed; refusing to clone with an unknown git \
         release"
    )]
    GitVersionUnparseable { raw: String },

    #[error(
        "git release `{found}` is below the minimum required `{required}` (lower releases carry \
         known CVE exposures such as CVE-2024-32002 path-attribute exploitation); install a newer \
         git on `$PATH`"
    )]
    GitVersionTooOld { found: String, required: String },

    #[error(
        "no `git` binary found on `$PATH`; install git or set `NYX_AGENT_GIT_BINARY_ALLOWLIST=*` \
         to bypass the path check"
    )]
    GitBinaryNotFound,

    #[error(
        "resolved git binary `{resolved:?}` is not on the trusted allowlist {allowlist:?}; set \
         `NYX_AGENT_GIT_BINARY_ALLOWLIST` to a colon-separated (semicolon on Windows) list of \
         trusted paths or `*` to disable the check"
    )]
    GitBinaryNotAllowed { resolved: PathBuf, allowlist: Vec<PathBuf> },

    #[error(
        "git remote URL on disk (`{actual}`) does not match the attested URL `{attested}` for \
         repo `{name}`"
    )]
    RemoteMismatch { name: String, attested: String, actual: String },

    #[error("local-path `{path}` does not exist for repo `{name}`")]
    LocalPathMissing { name: String, path: PathBuf },

    #[error("local-path `{path}` for repo `{name}` is not a directory")]
    LocalPathNotDir { name: String, path: PathBuf },

    #[error("git command failed for repo `{name}`: {message}")]
    Git { name: String, message: String },

    #[error("snapshot backend failed for repo `{name}`: {message}")]
    Snapshot { name: String, message: String },

    #[error("filesystem error while ingesting repo `{name}` at {path}: {source}")]
    Io {
        name: String,
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
}

/// Cleanup hook attached to an [`IngestedRepo`]. Local-path ingestions
/// install one; git clones do not (the checkout is reused across runs).
pub(crate) enum Cleanup {
    /// Recursively remove a directory at drop time. Used for the
    /// per-run local-path snapshot.
    RemoveSnapshot(PathBuf),
}

/// Workspace produced by [`ingest`]. Drop releases any per-run state
/// (local-path snapshots) but never touches the persistent git
/// checkout.
pub struct IngestedRepo {
    pub name: String,
    pub workspace: PathBuf,
    pub source: RepoSource,
    pub snapshot_backend: Option<SnapshotBackend>,
    pub on_disk_git_remote: Option<String>,
    pub(crate) cleanup: Option<Cleanup>,
}

impl std::fmt::Debug for IngestedRepo {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("IngestedRepo")
            .field("name", &self.name)
            .field("workspace", &self.workspace)
            .field("source", &self.source)
            .field("snapshot_backend", &self.snapshot_backend)
            .field("on_disk_git_remote", &self.on_disk_git_remote)
            .finish()
    }
}

impl IngestedRepo {
    /// Forget the cleanup hook so the workspace survives drop. Used by
    /// callers that want to inspect the workspace after ingestion in a
    /// test.
    pub fn leak_cleanup(&mut self) {
        self.cleanup = None;
    }
}

impl Drop for IngestedRepo {
    fn drop(&mut self) {
        if let Some(c) = self.cleanup.take() {
            match c {
                Cleanup::RemoveSnapshot(p) => {
                    let _ = local::force_remove_dir(&p);
                }
            }
        }
    }
}

/// Ingest one repository. Honours the ownership attestation, decodes
/// the auth descriptor, then dispatches to the git or local-path
/// backend. Writes only under `<state_repos>/<name>/`; never touches
/// other repos.
pub async fn ingest(
    repo: &Repo,
    state_repos: &Path,
    run_id: &str,
) -> Result<IngestedRepo, IngestError> {
    if !repo.i_own_this {
        return Err(IngestError::NotAttested { name: repo.name.clone() });
    }
    let per_repo = state_repos.join(&repo.name);
    std::fs::create_dir_all(&per_repo).map_err(|e| IngestError::Io {
        name: repo.name.clone(),
        path: per_repo.clone(),
        source: e,
    })?;

    match &repo.source {
        RepoSource::Git { url, branch, auth } => {
            git::ingest_git(&repo.name, url, branch.as_deref(), auth.as_ref(), &per_repo).await
        }
        RepoSource::LocalPath { path } => {
            local::ingest_local(&repo.name, path, &per_repo, run_id).await
        }
    }
}

pub(crate) fn install_snapshot_cleanup(repo: &mut IngestedRepo, snapshot_dir: PathBuf) {
    repo.cleanup = Some(Cleanup::RemoveSnapshot(snapshot_dir));
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::project::ProjectId;

    fn test_project_id() -> ProjectId {
        ProjectId::new("test-project")
    }

    #[test]
    fn auth_parse_ssh_key() {
        let a = parse_git_auth("ssh-key:/home/eli/.ssh/id_ed25519").expect("ok");
        assert_eq!(a, GitAuth::SshKey(PathBuf::from("/home/eli/.ssh/id_ed25519")));
    }

    #[test]
    fn auth_parse_token_env() {
        let a = parse_git_auth("token-env:GH_TOKEN").expect("ok");
        assert_eq!(a, GitAuth::TokenEnv("GH_TOKEN".to_string()));
    }

    #[test]
    fn auth_parse_gh_app() {
        let a = parse_git_auth("gh-app:12345").expect("ok");
        assert_eq!(a, GitAuth::GhApp("12345".to_string()));
    }

    #[test]
    fn auth_parse_rejects_unknown_scheme() {
        let err = parse_git_auth("kerberos:realm").expect_err("must reject");
        assert!(matches!(err, IngestError::AuthUnknownScheme { .. }));
    }

    #[test]
    fn auth_parse_rejects_missing_colon() {
        let err = parse_git_auth("token-env").expect_err("must reject");
        assert!(matches!(err, IngestError::AuthMalformed { .. }));
    }

    #[test]
    fn auth_descriptor_round_trips() {
        for raw in ["ssh-key:/home/eli/.ssh/key", "token-env:GH_TOKEN", "gh-app:12345"] {
            let parsed = parse_git_auth(raw).expect("parse");
            assert_eq!(parsed.descriptor(), raw, "descriptor must echo original config string");
        }
    }

    #[test]
    fn auth_parse_rejects_empty_value() {
        assert!(matches!(
            parse_git_auth("token-env:").expect_err("must reject"),
            IngestError::AuthMalformed { .. }
        ));
        assert!(matches!(
            parse_git_auth("gh-app:").expect_err("must reject"),
            IngestError::AuthMalformed { .. }
        ));
    }

    #[test]
    fn from_config_decodes_git_with_auth() {
        let cfg = RepoConfig {
            name: "billing".to_string(),
            i_own_this: true,
            source: RepoSourceConfig::Git {
                url: "git@github.com:org/billing.git".to_string(),
                branch: Some("main".to_string()),
                auth: Some("ssh-key:/home/eli/.ssh/key".to_string()),
            },
            enabled: true,
        };
        let r = repo_from_config(&cfg, test_project_id()).expect("from_config");
        match r.source {
            RepoSource::Git { url, branch, auth } => {
                assert_eq!(url, "git@github.com:org/billing.git");
                assert_eq!(branch.as_deref(), Some("main"));
                assert_eq!(auth, Some(GitAuth::SshKey(PathBuf::from("/home/eli/.ssh/key"))));
            }
            other => panic!("unexpected source {other:?}"),
        }
    }

    #[tokio::test]
    async fn two_repos_ingest_into_separate_workspace_dirs() {
        // Acceptance: a git-source repo and a local-path repo both
        // produce separate workspace dirs under <state>/repos/<name>/.
        let bare_dir = tempfile::tempdir().expect("bare");
        let bare = bare_dir.path().join("upstream.git");
        std::fs::create_dir_all(&bare).expect("mk bare");
        assert!(std::process::Command::new("git")
            .args(["init", "--bare", "--initial-branch=main"])
            .arg(&bare)
            .status()
            .expect("git init")
            .success());
        // Push a seed commit so the clone has a HEAD on main.
        let work = tempfile::tempdir().expect("work");
        for args in [
            vec!["clone", bare.to_str().unwrap(), work.path().to_str().unwrap()],
            vec!["-C", work.path().to_str().unwrap(), "config", "user.email", "t@x.y"],
            vec!["-C", work.path().to_str().unwrap(), "config", "user.name", "t"],
        ] {
            assert!(std::process::Command::new("git")
                .args(&args)
                .status()
                .expect("git step")
                .success());
        }
        std::fs::write(work.path().join("README.md"), "hi\n").expect("write");
        for args in [
            vec!["-C", work.path().to_str().unwrap(), "add", "README.md"],
            vec!["-C", work.path().to_str().unwrap(), "commit", "-m", "i"],
            vec!["-C", work.path().to_str().unwrap(), "push", "origin", "main"],
        ] {
            assert!(std::process::Command::new("git")
                .args(&args)
                .status()
                .expect("git step")
                .success());
        }

        let local_src = tempfile::tempdir().expect("local-src");
        std::fs::write(local_src.path().join("svc.py"), b"print('hi')").expect("write");

        let state = tempfile::tempdir().expect("state");
        let state_repos = state.path().to_path_buf();
        let git_repo = Repo {
            name: "billing".to_string(),
            source: RepoSource::Git {
                url: format!("file://{}", bare.display()),
                branch: Some("main".to_string()),
                auth: None,
            },
            i_own_this: true,
            project_id: test_project_id(),
        };
        let local_repo = Repo {
            name: "monolith".to_string(),
            source: RepoSource::LocalPath { path: local_src.path().to_path_buf() },
            i_own_this: true,
            project_id: test_project_id(),
        };
        let g = ingest(&git_repo, &state_repos, "run-1").await.expect("git ingest");
        let l = ingest(&local_repo, &state_repos, "run-1").await.expect("local ingest");
        assert!(g.workspace.starts_with(state_repos.join("billing")));
        assert!(l.workspace.starts_with(state_repos.join("monolith")));
        assert_ne!(g.workspace, l.workspace);
        assert!(g.workspace.join("README.md").exists());
        assert!(l.workspace.join("svc.py").exists());
    }

    #[tokio::test]
    async fn ingest_refuses_unattested_repo() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let repo = Repo {
            name: "unattested".to_string(),
            source: RepoSource::LocalPath { path: tmp.path().to_path_buf() },
            i_own_this: false,
            project_id: test_project_id(),
        };
        let err = ingest(&repo, tmp.path(), "run-1").await.expect_err("unattested must be refused");
        match err {
            IngestError::NotAttested { name } => assert_eq!(name, "unattested"),
            other => panic!("expected NotAttested, got {other:?}"),
        }
    }
}