@praxso/attested 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/util.js ADDED
@@ -0,0 +1,214 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, isAbsolute, join, relative, extname, sep } from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ export class AttestedError extends Error {
6
+ }
7
+ // Largest evidence file (trace, video, screenshot, report) that is hashed into a receipt.
8
+ export const MAX_EVIDENCE_BYTES = 512 * 1024 * 1024;
9
+ // Opens a regular file without following a symlink in its last component.
10
+ function openFile(path, what = 'file') {
11
+ let fd;
12
+ try {
13
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
14
+ }
15
+ catch (err) {
16
+ if (err.code === 'ELOOP')
17
+ throw new AttestedError(`attested: ${what} ${path} is a symlink; refusing to read it`);
18
+ throw err;
19
+ }
20
+ if (!fstatSync(fd).isFile()) {
21
+ closeSync(fd);
22
+ throw new AttestedError(`attested: ${what} ${path} is not a regular file; refusing to read it`);
23
+ }
24
+ return fd;
25
+ }
26
+ // Writes an output file without following a symlink: a committed `attested/receipt.json -> ../elsewhere` (or a
27
+ // symlinked output directory) would otherwise make a run overwrite a file outside the project. The data goes to a
28
+ // fresh temp file in the same directory, then rename() replaces the target itself, never what a link points at.
29
+ export function writeOutput(path, data) {
30
+ const dir = dirname(path);
31
+ if (isLink(dir))
32
+ throw new AttestedError(`attested: output directory ${dir} is a symlink; refusing to write through it`);
33
+ if (isLink(path))
34
+ throw new AttestedError(`attested: output ${path} is a symlink; refusing to write through it`);
35
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
36
+ try {
37
+ writeFileSync(tmp, data, { flag: 'wx' });
38
+ renameSync(tmp, path);
39
+ }
40
+ finally {
41
+ rmSync(tmp, { force: true });
42
+ }
43
+ }
44
+ function isLink(path) {
45
+ try {
46
+ return lstatSync(path).isSymbolicLink();
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ // Hashes in chunks, so a large evidence file is never held in memory; refuses files over `max` bytes.
53
+ export function sha256File(path, max = MAX_EVIDENCE_BYTES) {
54
+ const fd = openFile(path);
55
+ try {
56
+ const size = fstatSync(fd).size;
57
+ if (size > max)
58
+ throw new AttestedError(`attested: ${path} is ${size} bytes, over the ${max}-byte limit for evidence files`);
59
+ const hash = createHash('sha256');
60
+ const chunk = Buffer.alloc(1024 * 1024);
61
+ let bytes = 0;
62
+ for (let n; (n = readSync(fd, chunk, 0, chunk.length, null)) > 0;) {
63
+ bytes += n;
64
+ if (bytes > max)
65
+ throw new AttestedError(`attested: ${path} grew past the ${max}-byte limit for evidence files while hashing`);
66
+ hash.update(chunk.subarray(0, n));
67
+ }
68
+ return { sha256: hash.digest('hex'), bytes };
69
+ }
70
+ finally {
71
+ closeSync(fd);
72
+ }
73
+ }
74
+ // `path` (a file or directory under `root`) must not be a symlink and must resolve inside `root`, including through
75
+ // symlinked parent directories. Returns false when it does not exist; throws when it escapes.
76
+ export function checkInside(root, path, what) {
77
+ let st;
78
+ try {
79
+ st = lstatSync(path);
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ if (st.isSymbolicLink())
85
+ throw new AttestedError(`attested: ${what} ${path} is a symlink; refusing to read it`);
86
+ const rel = relative(realpathSync(root), realpathSync(path));
87
+ if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
88
+ throw new AttestedError(`attested: ${what} ${path} resolves outside ${root}; refusing to read it`);
89
+ return true;
90
+ }
91
+ // Reads `path` (under `root`) through one descriptor: opened first, then checked with checkInside and required to be the
92
+ // same file the path names now, and read with `max` enforced on the bytes actually read. What is checked, read, and
93
+ // hashed is one object, even if the path is replaced or the file grows meanwhile. Returns null when it does not exist.
94
+ // `afterOpen` is a test seam for changing the file between opening and validation.
95
+ export function readInside(root, path, max, what, afterOpen) {
96
+ let fd;
97
+ try {
98
+ fd = openFile(path, what);
99
+ }
100
+ catch (err) {
101
+ if (err.code === 'ENOENT')
102
+ return null;
103
+ throw err;
104
+ }
105
+ try {
106
+ afterOpen?.();
107
+ if (!checkInside(root, path, what))
108
+ throw new AttestedError(`attested: ${what} ${path} was removed while being read; refusing to read it`);
109
+ const st = fstatSync(fd);
110
+ const now = lstatSync(path);
111
+ if (st.dev !== now.dev || st.ino !== now.ino)
112
+ throw new AttestedError(`attested: ${what} ${path} was replaced while being read; refusing to read it`);
113
+ const chunks = [];
114
+ const chunk = Buffer.alloc(1024 * 1024);
115
+ let bytes = 0;
116
+ for (let n; (n = readSync(fd, chunk, 0, chunk.length, null)) > 0;) {
117
+ bytes += n;
118
+ if (bytes > max)
119
+ throw new AttestedError(`attested: ${what} ${path} is over the ${max}-byte limit`);
120
+ chunks.push(Buffer.from(chunk.subarray(0, n)));
121
+ }
122
+ return Buffer.concat(chunks);
123
+ }
124
+ finally {
125
+ closeSync(fd);
126
+ }
127
+ }
128
+ export function walk(dir) {
129
+ let out = [];
130
+ let entries;
131
+ try {
132
+ entries = readdirSync(dir);
133
+ }
134
+ catch {
135
+ return out;
136
+ }
137
+ for (const name of entries.sort()) {
138
+ const p = join(dir, name);
139
+ // lstat, not stat: a symlink in a results directory must not pull in files from outside it.
140
+ const st = lstatSync(p);
141
+ if (st.isDirectory())
142
+ out = out.concat(walk(p));
143
+ else if (st.isFile())
144
+ out.push(p);
145
+ }
146
+ return out;
147
+ }
148
+ export function classify(path) {
149
+ const ext = extname(path).toLowerCase();
150
+ if (ext === '.zip')
151
+ return 'trace';
152
+ if (ext === '.webm' || ext === '.mp4')
153
+ return 'video';
154
+ if (ext === '.png' || ext === '.jpg' || ext === '.jpeg')
155
+ return 'screenshot';
156
+ return 'report';
157
+ }
158
+ export function relPath(root, p) {
159
+ return relative(root, p).split('\\').join('/');
160
+ }
161
+ export function git(args, cwd) {
162
+ try {
163
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ export function gitHead(cwd) {
170
+ return git(['rev-parse', 'HEAD'], cwd);
171
+ }
172
+ export function gitDirty(cwd) {
173
+ const out = git(['status', '--porcelain', '--untracked-files=normal'], cwd);
174
+ return out === null ? false : out.length > 0;
175
+ }
176
+ export function gitRef(cwd) {
177
+ if (process.env.GITHUB_REF)
178
+ return process.env.GITHUB_REF;
179
+ const ref = git(['symbolic-ref', '-q', 'HEAD'], cwd);
180
+ return ref || null;
181
+ }
182
+ export function inActions() {
183
+ return process.env.GITHUB_ACTIONS === 'true';
184
+ }
185
+ export function runUrl() {
186
+ const { GITHUB_SERVER_URL, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT } = process.env;
187
+ if (!GITHUB_SERVER_URL || !GITHUB_REPOSITORY || !GITHUB_RUN_ID)
188
+ return null;
189
+ const attempt = GITHUB_RUN_ATTEMPT ? `/attempts/${GITHUB_RUN_ATTEMPT}` : '';
190
+ return `${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}${attempt}`;
191
+ }
192
+ export function warn(msg) {
193
+ process.stderr.write(`attested: warning: ${msg}\n`);
194
+ }
195
+ export function info(msg) {
196
+ process.stderr.write(`attested: ${msg}\n`);
197
+ }
198
+ // The webhook payload of the current workflow run, or null outside Actions.
199
+ export function readEvent() {
200
+ const path = process.env.GITHUB_EVENT_PATH;
201
+ if (!path || !existsSync(path))
202
+ return null;
203
+ try {
204
+ return JSON.parse(readFileSync(path, 'utf8'));
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ // `owner/repo/.github/workflows/x.yml` from GITHUB_WORKFLOW_REF, the form `gh attestation verify --signer-workflow` takes.
211
+ export function signerWorkflow() {
212
+ const ref = process.env.GITHUB_WORKFLOW_REF;
213
+ return ref ? ref.split('@')[0] : null;
214
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@praxso/attested",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Signed, verifiable test receipts for agent-authored pull requests: wraps Playwright or any CTRF test run into an in-toto receipt signed with Sigstore via GitHub artifact attestations.",
5
+ "keywords": [
6
+ "attestation",
7
+ "in-toto",
8
+ "sigstore",
9
+ "slsa",
10
+ "ctrf",
11
+ "playwright",
12
+ "github-actions",
13
+ "provenance",
14
+ "ai-agents",
15
+ "coding-agents",
16
+ "test-results",
17
+ "supply-chain"
18
+ ],
19
+ "homepage": "https://github.com/Praxso/attested#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/Praxso/attested/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/Praxso/attested.git",
26
+ "directory": "packages/cli"
27
+ },
28
+ "license": "Apache-2.0",
29
+ "author": "Praxso",
30
+ "type": "module",
31
+ "bin": {
32
+ "attested": "./bin/attested.js"
33
+ },
34
+ "files": [
35
+ "bin",
36
+ "dist",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "22.20.5",
45
+ "typescript": "5.9.3"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json",
52
+ "test": "node --test test/*.test.mjs"
53
+ }
6
54
  }